[Certs] Migrate UI from previous implementations

This commit is contained in:
Araozu 2023-08-24 17:31:40 -05:00
commit 3f49209c29
32 changed files with 4426 additions and 0 deletions

89
frontend/.eslintrc.yml Normal file
View File

@ -0,0 +1,89 @@
env:
browser: true
es2021: true
extends:
- 'eslint:recommended'
- 'plugin:@typescript-eslint/recommended'
parser: '@typescript-eslint/parser'
parserOptions:
ecmaVersion: 12
sourceType: module
plugins:
- '@typescript-eslint'
- react
rules:
"@typescript-eslint/ban-ts-comment": off
"@typescript-eslint/no-empty-function": off
indent:
- error
- 4
- SwitchCase: 1
linebreak-style:
- error
- unix
quotes:
- error
- double
semi:
- error
- always
react/jsx-pascal-case: error
react/jsx-closing-bracket-location: error
react/jsx-closing-tag-location: error
no-multi-spaces: error
react/jsx-tag-spacing: error
react/jsx-boolean-value: error
react/jsx-wrap-multilines: error
react/self-closing-comp: error
prefer-const: error
no-const-assign: error
no-var: error
array-callback-return: error
prefer-template: error
template-curly-spacing: error
no-useless-escape: error
wrap-iife: error
no-loop-func: error
default-param-last: error
space-before-function-paren:
- error
- never
space-before-blocks: error
no-param-reassign: error
function-paren-newline: error
comma-dangle:
- error
- always-multiline
arrow-spacing: error
arrow-parens: error
arrow-body-style: error
no-confusing-arrow: error
implicit-arrow-linebreak: error
no-duplicate-imports: error
object-curly-newline: error
dot-notation: error
one-var:
- error
- never
no-multi-assign: error
no-plusplus: error
operator-linebreak: error
eqeqeq: error
no-case-declarations: error
no-nested-ternary: error
no-unneeded-ternary: error
no-mixed-operators: error
nonblock-statement-body-position: error
brace-style: error
keyword-spacing: error
space-infix-ops: error
eol-last: error
newline-per-chained-call: error
no-whitespace-before-property: error
space-in-parens: error
array-bracket-spacing: error
key-spacing: error
no-trailing-spaces: error
comma-style: error
radix: error
no-new-wrappers: error

2
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

34
frontend/README.md Normal file
View File

@ -0,0 +1,34 @@
## Usage
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev` or `npm start`
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)

16
frontend/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
<title>Solid App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

32
frontend/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "eeg-system-frontend",
"version": "0.0.1",
"description": "",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"lint": "eslint --fix"
},
"license": "MIT",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"autoprefixer": "^10.4.15",
"eslint": "^8.47.0",
"eslint-plugin-react": "^7.33.2",
"postcss": "^8.4.28",
"solid-devtools": "^0.27.3",
"tailwindcss": "^3.3.3",
"typescript": "^5.1.3",
"vite": "^4.3.9",
"vite-plugin-solid": "^2.7.0"
},
"dependencies": {
"@solidjs/router": "^0.8.3",
"@types/qrcode": "^1.5.1",
"qrcode": "^1.5.3",
"solid-js": "^1.7.6"
}
}

3308
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

16
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,16 @@
import { Route, Routes } from "@solidjs/router";
import type { Component } from "solid-js";
import { Certs } from "./certs";
import { NavRail } from "./components/NavRail";
const App: Component = () => (
<div class="grid grid-cols-[5rem_auto]">
<NavRail />
<Routes>
<Route path="/certs" component={Certs} />
</Routes>
</div>
);
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,97 @@
import { FilledCard } from "../../components/FilledCard";
import { For } from "solid-js";
// import { subjects } from "src/views/subjects";
import { XIcon } from "../../icons/XIcon";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const subjects: () => Array<any> = () => [];
function isoDateToLocalDate(date: string): string {
const [,month, day] = /\d{4}-(\d{2})-(\d{2})/.exec(date) ?? "";
return `${day}/${month}`;
}
export function RegisterPreview(props: {selections: Array<[number, string]>, personId: number | null, onDelete: (v: number) => void, onRegister: () => void}) {
const submit = async() => {
console.log("Submit...");
for (const [courseId, date] of props.selections) {
const result = await defaultNewRegisterFn(
props.personId ?? -1,
courseId,
date,
);
if (result === null) {
console.log("Success");
} else {
console.log(`error. ${result}`);
}
}
props.onRegister();
};
return (
<FilledCard class="border border-c-outline overflow-hidden">
<h2 class="p-4 font-bold text-xl">Confirmar registro</h2>
<div class="bg-c-surface p-4">
<For each={props.selections}>
{([courseId, date]) => <Register courseId={courseId} date={date} onDelete={props.onDelete} />}
</For>
<button
class="bg-c-primary text-c-on-primary px-4 py-2 rounded-full cursor-pointer mt-4
disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
disabled={props.selections.length === 0}
onclick={submit}
>
Registrar los {props.selections.length} cursos
</button>
</div>
</FilledCard>
);
}
function Register(props: {courseId: number, date: string, onDelete: (v: number) => void}) {
const courseName = () => {
const courses = subjects();
return courses.find((c) => c.id === props.courseId)?.nombre ?? "!";
};
return (
<div class="grid grid-cols-[auto_4rem_1.5rem] py-1 px-2 rounded-md border border-c-outline my-1">
{courseName()}
<span class="font-mono">{isoDateToLocalDate(props.date)}</span>
<button
class="hover:bg-c-surface-variant rounded-md"
onclick={() => props.onDelete(props.courseId)}
>
<XIcon fill="var(--c-on-surface)" />
</button>
</div>
);
}
async function defaultNewRegisterFn(personId: number, subjectId: number, date: string): Promise<null | string> {
const response = await fetch("/certificate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
personId,
subjectId,
date,
}),
});
if (response.ok) {
return null;
} else {
const data = await response.json();
return JSON.stringify(data);
}
}

View File

@ -0,0 +1,109 @@
import { createEffect, createSignal, For } from "solid-js";
import type {CursoGIE} from "../../../model/CursoGIE/cursoGIE.entity";
import { isServer } from "solid-js/web";
export function SearchableSelect(props: {
subjects: Array<CursoGIE>,
onChange: (id: number | null) => void,
count: number
}) {
const [filter, setFilter] = createSignal("");
const [selected, setSelected] = createSignal<number | null>(null);
const [inputValue, setInputValue] = createSignal("");
const iHandler = (ev: KeyboardEvent & {currentTarget: HTMLInputElement, target: Element}) => {
const inputEl = ev.target as HTMLInputElement;
// Clear current selection
setSelected(null);
let filter: string = inputEl.value.toLowerCase();
filter = filter.replace("á", "a");
filter = filter.replace("é", "e");
filter = filter.replace("í", "i");
filter = filter.replace("ó", "o");
filter = filter.replace("ú", "u");
setFilter(filter);
};
createEffect(() => {
props.onChange(selected());
});
createEffect(() => {
// Makes reactivity happen
console.log(props.count);
setFilter("");
setSelected(null);
setInputValue("");
});
const inputElement = (
<input
id="create-subject"
class={`bg-c-background text-c-on-background
${selected() !== null ? "border-c-green" : "border-c-outline"}
border-2 rounded px-2 py-1
w-full
invalid:border-c-error invalid:text-c-error
focus:border-c-primary outline-none
disabled:opacity-50 disabled:cursor-not-allowed`}
type="text"
placeholder="Curso"
onkeyup={iHandler}
value={inputValue()}
onchange={(ev) => setInputValue(ev.target.value)}
autocomplete="off"
/>
);
if (!isServer) {
(inputElement as HTMLInputElement).addEventListener("keydown", (ev) => {
if (ev.code === "Enter") {
ev.preventDefault();
}
});
}
const filteredOptions = () => {
const filterText = filter();
return props.subjects.filter((subject) => {
let subjectText = subject.nombre.toLowerCase();
subjectText = subjectText.replace("á", "a");
subjectText = subjectText.replace("é", "e");
subjectText = subjectText.replace("í", "i");
subjectText = subjectText.replace("ó", "o");
subjectText = subjectText.replace("ú", "u");
return selected() === null && subjectText.indexOf(filterText) !== -1;
});
};
return (
<>
{inputElement}
<br/>
<div
class="border-c-outline border-2 rounded overflow-y-scroll h-[10rem]"
>
<For each={filteredOptions()}>
{(s) => (
<button
class="w-full text-left py-1 px-2
hover:bg-c-primary-container hover:text-c-on-primary-container"
onclick={(ev) => {
ev.preventDefault();
setSelected(s.id);
setInputValue(s.nombre);
}}
>
{s.nombre}
</button>
)}
</For>
</div>
</>
);
}

View File

@ -0,0 +1,156 @@
import { createSignal, Show } from "solid-js";
import { SearchableSelect } from "./SearchableSelect";
import { JSX } from "solid-js/jsx-runtime";
// import { subjects } from "../subjects";
import { FilledCard } from "../../components/FilledCard";
import { RegisterPreview } from "./RegisterPreview";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const subjects: () => Array<any> = () => [];
type HTMLEventFn = JSX.EventHandlerUnion<HTMLFormElement, Event & {
submitter: HTMLElement;
}>;
type TabType = "Presets" | "Manual";
export function NewRegister(props: {
personId: number | null,
onSuccess: () => void,
}) {
const [active, setActive] = createSignal<TabType>("Manual");
const [selections, setSelections] = createSignal<Array<[number, string]>>([]);
const onRegister = () => {
setSelections([]);
props.onSuccess();
};
return (
<div class="h-screen overflow-y-scroll">
<FilledCard class="border border-c-outline overflow-hidden">
<h2 class="p-4 font-bold text-xl">Registrar certs</h2>
<RegisterTabs active={active()} setActive={setActive} />
<div class="bg-c-surface p-4 h-[22rem]">
<Show when={active() === "Presets"}>
<p>Proximamente...</p>
</Show>
<Show when={active() === "Manual"}>
<ManualCerts personId={props.personId} onAdd={(v) => setSelections((x) => [...x, v])} />
</Show>
</div>
</FilledCard>
<RegisterPreview
selections={selections()}
personId={props.personId}
onDelete={(deleteId) => setSelections((s) => [...s.filter(([id]) => id !== deleteId)])}
onRegister={onRegister}
/>
</div>
);
}
function RegisterTabs(props: {active: TabType, setActive: (v: TabType) => void}) {
const presetsClasses = () => ((props.active === "Presets") ? "font-bold border-c-primary" : "border-c-transparent");
const manualClasses = () => ((props.active === "Manual") ? "font-bold border-c-primary" : "border-c-transparent");
return (
<div class="grid grid-cols-2">
<button
class={`py-2 border-b-4 ${presetsClasses()}`}
onclick={() => props.setActive("Presets")}
>
Presets
</button>
<button
class={`py-2 border-b-4 ${manualClasses()}`}
onclick={() => props.setActive("Manual")}
>
Manual
</button>
</div>
);
}
function ManualCerts(props: {personId: number | null, onAdd: (v: [number, string]) => void}) {
// Used to update SearchableSelect.tsx manually
const [count, setCount] = createSignal(0);
const [error, setError] = createSignal("");
const [selectedSubject, setSelectedSubject] = createSignal<number | null>(null);
const datePicker = (
<input
id="create-date"
class="bg-c-surface text-c-on-surface border border-c-outline rounded-lg p-2"
type="date"
/>
);
const register: HTMLEventFn = async(ev) => {
ev.preventDefault();
const subject = selectedSubject();
const date = (datePicker as HTMLInputElement).value;
if (subject === null) {
setError("Selecciona un curso");
setTimeout(() => setError(""), 5000);
return;
}
if (date === "") {
setError("Selecciona una fecha");
setTimeout(() => setError(""), 5000);
return;
}
props.onAdd([subject, date]);
// This is used to update & refresh the <SearchableSelect> component
setCount((x) => x + 1);
};
console.log(`Person ID: ${props.personId}`);
return (
<>
<form onsubmit={register}>
<div>
<SearchableSelect
subjects={subjects()}
onChange={setSelectedSubject}
count={count()}
/>
</div>
<div class="relative my-4">
{datePicker}
<label for="create-date" class="absolute -top-2 left-2 text-xs bg-c-surface px-1">Fecha</label>
</div>
<input
class="bg-c-primary text-c-on-primary px-4 py-2 rounded-full cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed"
type="submit"
value="Agregar"
disabled={props.personId === null}
/>
</form>
<p
class="my-2 p-1 rounded w-fit mx-4 bg-c-error text-c-on-error"
style={{opacity: error() === "" ? "0" : "1", "user-select": "none"}}
>
{error()}&nbsp;
</p>
</>
);
}

View File

@ -0,0 +1,299 @@
import { createEffect, createSignal, Show } from "solid-js";
import { JSX } from "solid-js/jsx-runtime";
// import { Person } from "../../types/Person";
// import { RegisterPerson } from "./Search/RegisterPerson";
import QR from "qrcode";
import { CopyIcon } from "../icons/CopyIcon";
import { MagnifyingGlassIcon } from "../icons/MagnifyingGlassIcon";
import { XIcon } from "../icons/XIcon";
type HTMLEventFn = JSX.EventHandlerUnion<HTMLFormElement, Event & {
submitter: HTMLElement;
}>;
type HTMLButtonEvent = JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Person = any;
/*
Form that retrieves a user from the DB given an ID
*/
export function Search(props: {setPerson: (p: Person | null) => void}) {
const [dni, setDni] = createSignal("");
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal("");
const [warning, setWarning] = createSignal("");
const [qrBase64, setQrBase64] = createSignal<string | null>(null);
const [persona, setPersona] = createSignal<Person | null>(null);
// Update QR
createEffect(() => {
if (dni() !== "") {
// Old URL: https://www.eegsac.com/alumnoscertificados.php?DNI=${dni()}
// New URL: https://eegsac.com/certificado/${dni()}
QR.toDataURL(`https://eegsac.com/certificado/${dni()}`, {margin: 1}, (err, res) => {
if (err) {
console.error("Error creating QR code");
return;
}
setQrBase64(res);
});
}
});
/*
Get the user data from the DB
*/
const searchDNI: HTMLEventFn = (ev) => {
ev.preventDefault();
search();
};
const search = async() => {
setLoading(true);
setError("");
setWarning("");
try {
const response = await fetch(`/person/${dni()}`);
const body = await response.json();
if (response.ok) {
setPersona(body);
props.setPerson(body);
} else if (response.status === 404) {
console.error(body);
setWarning("Persona no encontrada. Se debe insertar manualmente sus datos.");
props.setPerson(null);
} else {
setError(body);
}
} catch (e) {
setError(JSON.stringify(e));
}
setLoading(false);
};
const nombresYApellidos = () => {
const p = persona();
if (p === null) {
return "";
}
return `${p.nombres} ${p.apellidoPaterno} ${p.apellidoMaterno}`;
};
const apellidosYNombres = () => {
const p = persona();
if (p === null) {
return "";
}
return `${p.apellidoPaterno} ${p.apellidoMaterno} ${p.nombres}`;
};
const apellidos = () => {
const p = persona();
if (p === null) {
return "";
}
return `${p.apellidoPaterno} ${p.apellidoMaterno}`;
};
const nombres = () => {
const p = persona();
if (p === null) {
return "";
}
return p.nombres;
};
return (
<div>
<div class="text-center">
<div class="my-4 inline-block w-[10rem] h-[10rem] rounded-lg bg-c-surface-variant overflow-hidden">
<img class={`${qrBase64() === null ? "hidden" : ""} inline-block w-[10rem] h-[10rem]`} src={qrBase64() ?? ""} />
</div>
</div>
<div>
<form onSubmit={searchDNI} class="px-4">
<InputBox dni={dni()} setDni={setDni} loading={loading()} />
</form>
<p
class="relative max-w-[14rem] mx-auto p-1 text-c-error text-sm"
style={{display: error() === "" ? "none" : "block"}}
>
Error:
<br />
{error()}
</p>
<br />
<div class={`${persona() === null ? "opacity-50 cursor-not-allowed" : ""}`}>
<MaterialLabel text={persona()?.apellidoPaterno ?? null} resource="Apellido Paterno" />
<MaterialLabel text={persona()?.apellidoMaterno ?? null} resource="Apellido Materno" />
<MaterialLabel text={persona()?.nombres ?? null} resource="Nombres" />
<div class={"relative max-w-[14rem] mx-auto my-6"}>
<CopyButton copyText={nombresYApellidos()}>
<CopyIcon fill="var(--c-on-primary)" />
&nbsp;
Nombres y <b>Apellidos</b>
</CopyButton>
<CopyButton copyText={apellidosYNombres()}>
<CopyIcon fill="var(--c-on-primary)" />
&nbsp;
<b>Apellidos</b> y Nombres
</CopyButton>
<div class="grid grid-cols-2 gap-2">
<CopyButton copyText={apellidos()}>
<CopyIcon fill="var(--c-on-primary)" />
&nbsp;
<b>Apellidos</b>
</CopyButton>
<CopyButton copyText={nombres()}>
<CopyIcon fill="var(--c-on-primary)" />
&nbsp;
Nombres
</CopyButton>
</div>
</div>
</div>
<Show when={warning() !== ""}>
{/*
<RegisterPerson dni={dni()} onSuccess={search} />
*/}
</Show>
</div>
</div>
);
}
function InputBox(props: {
loading: boolean,
dni: string,
setDni: (v: string) => void,
}) {
const inputElement = (
<input
id="search-dni"
class="bg-c-background text-c-on-background border-c-outline border-2 rounded px-2 py-1 w-full
invalid:border-c-error invalid:text-c-error
focus:border-c-primary outline-none font-mono
disabled:opacity-50 disabled:cursor-not-allowed"
type="text"
minLength={8}
maxLength={8}
pattern="[0-9]{8}"
placeholder="Número de DNI"
value={props.dni}
required
onChange={(e) => props.setDni(e.target.value)}
disabled={props.loading}
/>
);
const copyToClipboard: HTMLButtonEvent = (ev) => {
ev.preventDefault();
if (props.dni.length === 8) {
navigator.clipboard.writeText(props.dni);
}
};
const clearDni: HTMLButtonEvent = (ev) => {
ev.preventDefault();
props.setDni("");
(inputElement as HTMLInputElement).focus();
};
return (
<div class="relative max-w-[14rem] mx-auto">
{inputElement}
<label for="search-dni" class="absolute -top-2 left-2 text-xs bg-c-surface px-1">DNI</label>
<button
class="absolute top-1 right-[3.75rem] rounded hover:bg-c-surface-variant"
>
<MagnifyingGlassIcon fill="var(--c-on-surface)" />
</button>
<button
type="button"
class="absolute top-1 right-8 rounded hover:bg-c-surface-variant"
onclick={copyToClipboard}
>
<CopyIcon fill="var(--c-on-surface)" />
</button>
<button
type="button"
class="absolute top-1 right-1 rounded hover:bg-c-surface-variant"
onclick={clearDni}
>
<XIcon fill="var(--c-on-surface)" />
</button>
</div>
);
}
function MaterialLabel(props: {text: string | null, resource: string}) {
const copyToClipboard: HTMLButtonEvent = (ev) => {
ev.preventDefault();
if (props.text !== null) {
navigator.clipboard.writeText(props.text);
}
};
return (
<div class="relative max-w-[14rem] mx-auto my-6">
<label for="search-dni" class="absolute -top-2 left-2 text-xs bg-c-surface px-1 select-none">{props.resource}</label>
<span
class="bg-c-background text-c-on-background border-c-outline
border-2 rounded px-2 py-1 w-full inline-block font-mono
disabled:opacity-50 disabled:cursor-not-allowed"
>
{props.text ?? ""}
<span class="select-none">&nbsp;</span>
</span>
<button
type="button"
class="absolute top-1 right-1 rounded hover:bg-c-surface-variant"
onclick={copyToClipboard}
>
<CopyIcon fill="var(--c-on-surface)" />
</button>
</div>
);
}
function CopyButton(props: {copyText: string, children: Array<JSX.Element> | JSX.Element}) {
const [successAnimation, setSuccessAnimation] = createSignal(false);
const onclick = () => {
if (props.copyText !== "") {
navigator.clipboard.writeText(props.copyText);
setSuccessAnimation(true);
setTimeout(() => setSuccessAnimation(false), 500);
}
};
return (
<button
onclick={onclick}
class={
`${successAnimation() ? "bg-c-success text-c-on-success" : "bg-c-primary text-c-on-primary"
} rounded-lg transition-colors py-1 my-1 relative overflow-hidden inline-block w-full`
}
>
{props.children}
</button>
);
}

View File

@ -0,0 +1,12 @@
import { NewRegister } from "./NewRegister";
import { Search } from "./Search";
export function Certs() {
return (
<div class="grid grid-cols-[18rem_25rem_1fr]">
<Search setPerson={() => {}} />
<NewRegister personId={0} onSuccess={() => {}} />
<div>Registers</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
import type { JSX } from "solid-js";
export function FilledCard(props: {children?: Array<JSX.Element> | JSX.Element, class?: string}) {
return (
<div class={`bg-c-surface-variant text-c-on-surface-variant rounded-xl m-2 shadow ${props.class ?? ""}`}>
{props.children}
</div>
);
}

View File

@ -0,0 +1,47 @@
import { HomeIcon } from "../icons/HomeIcon";
import type {JSX} from "solid-js";
import { DocxIcon } from "../icons/DocxIcon";
import { StackIcon } from "../icons/StackIcon";
import { ScanIcon } from "../icons/ScanIcon";
import { KeyIcon } from "../icons/KeyIcon";
import { PaletteIcon } from "../icons/PaletteIcon";
import { A } from "@solidjs/router";
export function NavRail() {
return (
<div class="flex flex-col justify-center items-center h-screen overflow-x-hidden">
<NavRailButton path="/" name="Inicio" icon={<HomeIcon />} />
<NavRailButton path="/certs" name="Certs" icon={<DocxIcon />} />
<NavRailButton path="/batch" name="Batch" icon={<StackIcon />} />
<NavRailButton path="/accesos" name="Accesos" icon={<KeyIcon />} />
<NavRailButton path="/escaneo" name="Escaneo" icon={<ScanIcon />} />
<NavRailButton path="/colores" name="Colores" icon={<PaletteIcon />} />
</div>
);
}
function NavRailButton(props: {
name: string,
icon: JSX.Element,
path: string,
}) {
const anchorEl = (
<A
class="my-3 text-center group hover:bg-c-surface-variant rounded-xl transition-colors
max-w-[4rem] inline-block select-none"
href={props.path}
end
>
<div class={"p-1 inline-block group-[.active]:bg-c-surface-variant min-w-[4rem] rounded-full transition-colors"}>
{props.icon}
</div>
<span class="text-sm">
{props.name}
</span>
</A>
);
return anchorEl;
}

View File

@ -0,0 +1,5 @@
export function CopyIcon(props: {fill: string}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-6" fill={props.fill} viewBox="0 0 256 256"><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"></path></svg>
);
}

View File

@ -0,0 +1,5 @@
export function DocxIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-6" fill="var(--c-on-surface-variant)" viewBox="0 0 256 256"><path d="M52,144H36a8,8,0,0,0-8,8v56a8,8,0,0,0,8,8H52a36,36,0,0,0,0-72Zm0,56H44V160h8a20,20,0,0,1,0,40Zm169.53-4.91a8,8,0,0,1,.25,11.31A30.06,30.06,0,0,1,200,216c-17.65,0-32-16.15-32-36s14.35-36,32-36a30.06,30.06,0,0,1,21.78,9.6,8,8,0,0,1-11.56,11.06A14.24,14.24,0,0,0,200,160c-8.82,0-16,9-16,20s7.18,20,16,20a14.24,14.24,0,0,0,10.22-4.66A8,8,0,0,1,221.53,195.09ZM128,144c-17.65,0-32,16.15-32,36s14.35,36,32,36,32-16.15,32-36S145.65,144,128,144Zm0,56c-8.82,0-16-9-16-20s7.18-20,16-20,16,9,16,20S136.82,200,128,200ZM48,120a8,8,0,0,0,8-8V40h88V88a8,8,0,0,0,8,8h48v16a8,8,0,0,0,16,0V88a8,8,0,0,0-2.34-5.66l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40v72A8,8,0,0,0,48,120ZM160,51.31,188.69,80H160Z"></path></svg>
);
}

View File

@ -0,0 +1,5 @@
export function DownloadIcon(props: {fill: string}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-6" fill={props.fill} viewBox="0 0 256 256"><path d="M240,136v64a16,16,0,0,1-16,16H32a16,16,0,0,1-16-16V136a16,16,0,0,1,16-16H72a8,8,0,0,1,0,16H32v64H224V136H184a8,8,0,0,1,0-16h40A16,16,0,0,1,240,136Zm-117.66-2.34a8,8,0,0,0,11.32,0l48-48a8,8,0,0,0-11.32-11.32L136,108.69V24a8,8,0,0,0-16,0v84.69L85.66,74.34A8,8,0,0,0,74.34,85.66ZM200,168a12,12,0,1,0-12,12A12,12,0,0,0,200,168Z"></path></svg>
);
}

View File

@ -0,0 +1,5 @@
export function HomeIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-6" fill="var(--c-on-surface-variant)" viewBox="0 0 256 256"><path d="M218.83,103.77l-80-75.48a1.14,1.14,0,0,1-.11-.11,16,16,0,0,0-21.53,0l-.11.11L37.17,103.77A16,16,0,0,0,32,115.55V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V115.55A16,16,0,0,0,218.83,103.77ZM208,208H48V115.55l.11-.1L128,40l79.9,75.43.11.1Z"></path></svg>
);
}

View File

@ -0,0 +1,5 @@
export function KeyIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-6" fill="var(--c-on-surface-variant)" viewBox="0 0 256 256"><path d="M48,56V200a8,8,0,0,1-16,0V56a8,8,0,0,1,16,0Zm84,54.5L112,117V96a8,8,0,0,0-16,0v21L76,110.5a8,8,0,0,0-5,15.22l20,6.49-12.34,17a8,8,0,1,0,12.94,9.4l12.34-17,12.34,17a8,8,0,1,0,12.94-9.4l-12.34-17,20-6.49A8,8,0,0,0,132,110.5ZM238,115.64A8,8,0,0,0,228,110.5L208,117V96a8,8,0,0,0-16,0v21l-20-6.49a8,8,0,0,0-4.95,15.22l20,6.49-12.34,17a8,8,0,1,0,12.94,9.4l12.34-17,12.34,17a8,8,0,1,0,12.94-9.4l-12.34-17,20-6.49A8,8,0,0,0,238,115.64Z"></path></svg>
);
}

View File

@ -0,0 +1,5 @@
export function MagnifyingGlassIcon(props: {fill: string}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-6" fill={props.fill} viewBox="0 0 256 256"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"></path></svg>
);
}

View File

@ -0,0 +1,5 @@
export function PaletteIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-6" fill="var(--c-on-surface-variant)" viewBox="0 0 256 256" style="--darkreader-inline-fill: #000000;" data-darkreader-inline-fill=""><path d="M200.77,53.89A103.27,103.27,0,0,0,128,24h-1.07A104,104,0,0,0,24,128c0,43,26.58,79.06,69.36,94.17A32,32,0,0,0,136,192a16,16,0,0,1,16-16h46.21a31.81,31.81,0,0,0,31.2-24.88,104.43,104.43,0,0,0,2.59-24A103.28,103.28,0,0,0,200.77,53.89Zm13,93.71A15.89,15.89,0,0,1,198.21,160H152a32,32,0,0,0-32,32,16,16,0,0,1-21.31,15.07C62.49,194.3,40,164,40,128a88,88,0,0,1,87.09-88h.9a88.35,88.35,0,0,1,88,87.25A88.86,88.86,0,0,1,213.81,147.6ZM140,76a12,12,0,1,1-12-12A12,12,0,0,1,140,76ZM96,100A12,12,0,1,1,84,88,12,12,0,0,1,96,100Zm0,56a12,12,0,1,1-12-12A12,12,0,0,1,96,156Zm88-56a12,12,0,1,1-12-12A12,12,0,0,1,184,100Z"></path></svg>
);
}

View File

@ -0,0 +1,5 @@
export function ScanIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-6" fill="var(--c-on-surface-variant)" viewBox="0 0 256 256"><path d="M224,40V80a8,8,0,0,1-16,0V48H176a8,8,0,0,1,0-16h40A8,8,0,0,1,224,40ZM80,208H48V176a8,8,0,0,0-16,0v40a8,8,0,0,0,8,8H80a8,8,0,0,0,0-16Zm136-40a8,8,0,0,0-8,8v32H176a8,8,0,0,0,0,16h40a8,8,0,0,0,8-8V176A8,8,0,0,0,216,168ZM40,88a8,8,0,0,0,8-8V48H80a8,8,0,0,0,0-16H40a8,8,0,0,0-8,8V80A8,8,0,0,0,40,88Zm128,96H88a16,16,0,0,1-16-16V88A16,16,0,0,1,88,72h80a16,16,0,0,1,16,16v80A16,16,0,0,1,168,184ZM88,168h80V88H88Z"></path></svg>
);
}

View File

@ -0,0 +1,5 @@
export function StackIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-6" fill="var(--c-on-surface-variant)" viewBox="0 0 256 256"><path d="M230.91,172A8,8,0,0,1,228,182.91l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,36,169.09l92,53.65,92-53.65A8,8,0,0,1,230.91,172ZM220,121.09l-92,53.65L36,121.09A8,8,0,0,0,28,134.91l96,56a8,8,0,0,0,8.06,0l96-56A8,8,0,1,0,220,121.09ZM24,80a8,8,0,0,1,4-6.91l96-56a8,8,0,0,1,8.06,0l96,56a8,8,0,0,1,0,13.82l-96,56a8,8,0,0,1-8.06,0l-96-56A8,8,0,0,1,24,80Zm23.88,0L128,126.74,208.12,80,128,33.26Z"></path></svg>
);
}

View File

@ -0,0 +1,5 @@
export function TrashIcon(props: {fill: string}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-6" fill={props.fill} viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
);
}

View File

@ -0,0 +1,6 @@
export function XIcon(props: {fill: string}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-6" fill={props.fill} viewBox="0 0 256 256"><path d="M165.66,101.66,139.31,128l26.35,26.34a8,8,0,0,1-11.32,11.32L128,139.31l-26.34,26.35a8,8,0,0,1-11.32-11.32L116.69,128,90.34,101.66a8,8,0,0,1,11.32-11.32L128,116.69l26.34-26.35a8,8,0,0,1,11.32,11.32ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path></svg>
);
}

55
frontend/src/index.css Normal file
View File

@ -0,0 +1,55 @@
:root {
--c-primary: #b6c4ff;
--c-on-primary: #00287d;
--c-primary-container: #003baf;
--c-on-primary-container: #dce1ff;
--c-error: #ffb4ab;
--c-on-error: #690005;
--c-error-container: #93000a;
--c-on-error-container: #ffdad6;
--c-background: #1b1b1f;
--c-on-background: #e4e1e6;
--c-surface: #1b1b1f;
--c-on-surface: #e4e1e6;
--c-outline: #8f909a;
--c-surface-variant: #45464f;
--c-on-surface-variant: #c6c6d0;
--c-success: #78da9f;
--c-on-success: #00391f;
}
@media (prefers-color-scheme: light) {
:root {
--c-primary: #2a55cb;
--c-on-primary: #ffffff;
--c-primary-container: #dce1ff;
--c-on-primary-container: #00164f;
--c-error: #ba1a1a;
--c-on-error: #ffffff;
--c-error-container: #ffdad6;
--c-on-error-container: #410002;
--c-background: #fefbff;
--c-on-background: #1b1b1f;
--c-surface: #fefbff;
--c-on-surface: #1b1b1f;
--c-outline: #767680;
--c-surface-variant: #e2e1ec;
--c-on-surface-variant: #45464f;
--c-success: #006d40;
--c-on-success: #ffffff;
}
}
body {
background-color: var(--c-background);
color: var(--c-on-background);
font-family: Inter, "Inter Nerd Font", sans-serif;
}
@tailwind base;
@tailwind components;
@tailwind utilities;

14
frontend/src/index.tsx Normal file
View File

@ -0,0 +1,14 @@
/* @refresh reload */
import { render } from "solid-js/web";
import { Router } from "@solidjs/router";
import "./index.css";
import App from "./App";
const root = document.getElementById("root");
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error("Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?");
}
render(() => <Router><App /></Router>, root!);

View File

@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
},
colors: {
"c-primary": "var(--c-primary)",
"c-on-primary": "var(--c-on-primary)",
"c-primary-container": "var(--c-primary-container)",
"c-on-primary-container": "var(--c-on-primary-container)",
"c-error": "var(--c-error)",
"c-on-error": "var(--c-on-error)",
"c-error-container": "var(--c-error-container)",
"c-on-error-container": "var(--c-on-error-container)",
"c-background": "var(--c-background)",
"c-on-background": "var(--c-on-background)",
"c-surface": "var(--c-surface)",
"c-on-surface": "var(--c-on-surface)",
"c-outline": "var(--c-outline)",
"c-surface-variant": "var(--c-surface-variant)",
"c-on-surface-variant": "var(--c-on-surface-variant)",
"c-green": "#006b54",
"c-success": "var(--c-success)",
"c-on-success": "var(--c-on-success)",
"c-transparent": "rgba(0,0,0,0)",
},
},
plugins: [],
};

15
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"noEmit": true,
"isolatedModules": true
}
}

20
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
// import devtools from 'solid-devtools/vite';
export default defineConfig({
plugins: [
/*
Uncomment the following line to enable solid-devtools.
For more info see https://github.com/thetarnav/solid-devtools/tree/main/packages/extension#readme
*/
// devtools(),
solidPlugin(),
],
server: {
port: 3000,
},
build: {
target: 'esnext',
},
});