initial commit
This commit is contained in:
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.3 --activate
|
||||
|
||||
COPY package.json pnpm-lock.yaml* /app/
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
COPY . /app
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["pnpm", "dev", "--host", "0.0.0.0", "--port", "5173"]
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TermSearch</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "termsearch-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.cjs
Normal file
6
frontend/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
204
frontend/src/App.tsx
Normal file
204
frontend/src/App.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Definition = {
|
||||
source: string;
|
||||
title: string;
|
||||
url: string;
|
||||
definition: string;
|
||||
};
|
||||
|
||||
type DefinitionResponse = {
|
||||
term: string;
|
||||
results: Definition[];
|
||||
taxonomy?: TaxonomyMatch[];
|
||||
};
|
||||
|
||||
type TaxonomyMatch = {
|
||||
category: string;
|
||||
class_name: string;
|
||||
class_code: string;
|
||||
type_description?: string | null;
|
||||
type_code?: string | null;
|
||||
annex?: string | null;
|
||||
full_name: string;
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL?.toString() || "http://localhost:8000";
|
||||
|
||||
export default function App() {
|
||||
const [term, setTerm] = useState("");
|
||||
const [results, setResults] = useState<Definition[]>([]);
|
||||
const [taxonomy, setTaxonomy] = useState<TaxonomyMatch[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSearch = term.trim().length > 0 && !loading;
|
||||
|
||||
const apiUrl = useMemo(() => {
|
||||
const url = new URL("/api/definitions", API_BASE_URL);
|
||||
url.searchParams.set("term", term.trim());
|
||||
return url.toString();
|
||||
}, [term]);
|
||||
|
||||
const handleSearch = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!canSearch) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch definitions.");
|
||||
}
|
||||
const data = (await response.json()) as DefinitionResponse;
|
||||
setResults(data.results ?? []);
|
||||
setTaxonomy(data.taxonomy ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen px-6 py-12">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-8">
|
||||
<header className="space-y-3">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-sky-600">
|
||||
TermSearch
|
||||
</p>
|
||||
<h1 className="text-4xl font-semibold text-slate-900">
|
||||
Oil & Gas term definitions
|
||||
</h1>
|
||||
<p className="text-base text-slate-600">
|
||||
Search multiple glossary sources from a single interface.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={handleSearch}
|
||||
className="flex flex-col gap-4 rounded-2xl bg-white p-6 shadow-sm"
|
||||
>
|
||||
<label className="text-sm font-medium text-slate-700" htmlFor="term">
|
||||
Search term
|
||||
</label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
id="term"
|
||||
name="term"
|
||||
type="text"
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
placeholder="Ex: gas lift"
|
||||
className="flex-1 rounded-xl border border-slate-200 px-4 py-3 text-base focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSearch}
|
||||
className="rounded-xl bg-sky-600 px-6 py-3 text-base font-semibold text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{loading ? "Searching..." : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
API base: <span className="font-medium">{API_BASE_URL}</span>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-slate-800">Results</h2>
|
||||
<span className="text-sm text-slate-500">
|
||||
{results.length} {results.length === 1 ? "source" : "sources"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results.length === 0 && !loading ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-200 bg-white p-6 text-sm text-slate-500">
|
||||
No definitions yet. Try searching for a term.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{results.map((result) => (
|
||||
<article
|
||||
key={`${result.source}-${result.title}`}
|
||||
className="rounded-xl border border-slate-100 bg-white p-5 shadow-sm"
|
||||
>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-sky-600">
|
||||
{result.source}
|
||||
</h3>
|
||||
<p className="mt-2 text-lg font-semibold text-slate-900">
|
||||
{result.title}
|
||||
</p>
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex text-sm font-medium text-sky-600 hover:text-sky-700"
|
||||
>
|
||||
View source
|
||||
</a>
|
||||
<p className="mt-2 text-base text-slate-700">
|
||||
{result.definition}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-slate-800">
|
||||
ISO 14224 Taxonomy
|
||||
</h2>
|
||||
<span className="text-sm text-slate-500">
|
||||
{taxonomy.length} {taxonomy.length === 1 ? "match" : "matches"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{taxonomy.length === 0 && !loading ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-200 bg-white p-6 text-sm text-slate-500">
|
||||
No taxonomy matches found.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{taxonomy.map((item) => (
|
||||
<article
|
||||
key={`${item.class_code}-${item.type_code ?? "class"}`}
|
||||
className="rounded-xl border border-slate-100 bg-white p-5 shadow-sm"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-emerald-600">
|
||||
{item.category}
|
||||
</p>
|
||||
<h3 className="mt-2 text-lg font-semibold text-slate-900">
|
||||
{item.full_name}
|
||||
</h3>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-sm text-slate-600">
|
||||
<span>Class: {item.class_name} ({item.class_code})</span>
|
||||
{item.type_description ? (
|
||||
<span>
|
||||
Type: {item.type_description} ({item.type_code})
|
||||
</span>
|
||||
) : null}
|
||||
{item.annex ? <span>Annex: {item.annex}</span> : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
frontend/src/index.css
Normal file
11
frontend/src/index.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
8
frontend/tailwind.config.js
Normal file
8
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
17
frontend/tsconfig.json
Normal file
17
frontend/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
9
frontend/tsconfig.node.json
Normal file
9
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
10
frontend/vite.config.ts
Normal file
10
frontend/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user