First usable version

This commit is contained in:
Tanguy Gérôme 2026-01-05 23:39:34 +02:00
parent 1ae1539ee0
commit 7d3ca6c2c6
Signed by: tanguy
GPG key ID: 10B2947233740B88
38 changed files with 6070 additions and 0 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules
.git
.gitignore
*.md
dist
data

4
.gitignore vendored
View file

@ -1,3 +1,7 @@
data
target
# ---> Node # ---> Node
# Logs # Logs
logs logs

26
Dockerfile Normal file
View file

@ -0,0 +1,26 @@
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
COPY . /app
WORKDIR /app
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
ENV VITE_PLATFORM_ROOT=https://kahvi.juustodiilerit.fi
ENV VITE_RAUTHY_CLIENT_ID=kahvi-prod
ENV VITE_RAUTHY_REDIRECT_URL=https://kahvi.juustodiilerit.fi/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
FROM base
COPY --from=prod-deps /app/frontend/node_modules /app/frontend/node_modules
COPY --from=build /app/frontend/dist /app/frontend/dist
COPY --from=prod-deps /app/shared/node_modules /app/shared/node_modules
COPY --from=build /app/shared/dist /app/shared/dist
COPY --from=prod-deps /app/backend/node_modules /app/backend/node_modules
COPY --from=build /app/backend/dist /app/backend/dist
EXPOSE 3000
CMD [ "sh", "-c", "(cd ./backend && pnpx knex migrate:latest) && pnpm run start" ]

2
backend/.env.example Normal file
View file

@ -0,0 +1,2 @@
VITE_RAUTHY_CLIENT_ID=kahvi-localhost

5
backend/@types/express/index.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
declare namespace Express{
interface Request{
userId: string | undefined
}
}

229
backend/app.ts Normal file
View file

@ -0,0 +1,229 @@
import path from 'path'
import express, { NextFunction, Request, Response } from 'express'
import Knex from 'knex'
import jose from 'jose'
import * as z from 'zod'
// this `data` folder should be mounted in the docker-compose in production, or mkdired in development
const dbPath = path.join(process.env.DB_PREFIX ?? '../data/', '/database.db')
console.log(dbPath)
const knex = Knex({
client: 'better-sqlite3',
connection: {
filename: dbPath,
},
});
const app = express();
app.use(express.json());
// app.use(express.urlencoded());
const authenticatedRouter = express.Router()
const JWKS = jose.createRemoteJWKSet(new URL('https://rauthy.juustodiilerit.fi/auth/v1/oidc/certs'))
authenticatedRouter.use(async (req, res, next) => {
const authHeader = req.headers['authorization']
const jwt = authHeader && authHeader.split(' ')[1]
if (!jwt) {
return res.status(401).send('Unauthorized')
}
try {
const { payload } = await jose.jwtVerify(jwt, JWKS, {
issuer: 'https://rauthy.juustodiilerit.fi/auth/v1',
audience: process.env.VITE_RAUTHY_CLIENT_ID,
})
if (!payload.sub) {
return res.status(401).send('Unauthorized')
}
req.userId = payload.sub;
return next();
} catch (error) {
console.error(error)
}
return res.status(401).send('Unauthorized')
});
const validateBody = (schema: z.Schema) => async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedData = await schema.parseAsync(req.body);
req.body = validatedData;
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).send('Bad request');
} else {
return res.status(500).send('Internal server error');
}
}
};
// ==============================================
// COFFEES
// ==============================================
const CoffeePostBody = z.object({
name: z.string(),
roastLevel: z.int(),
notes: z.union([z.string(), z.undefined()])
})
type CoffeePostBody = z.infer<typeof CoffeePostBody>
authenticatedRouter.post('/coffee', validateBody(CoffeePostBody), async (req: Request, res: Response) => {
const coffee: CoffeePostBody = req.body
try {
await knex('coffee').insert({
id: knex.fn.uuid(),
user_id: req.userId,
name: coffee.name,
roast_level: coffee.roastLevel,
notes: coffee.notes,
})
} catch (error) {
console.error(error)
return res.status(500).send('Internal server error');
}
return res.status(200).send('Great success')
});
authenticatedRouter.get('/coffee', async (req: Request, res: Response) => {
const coffees = await knex('coffee').select('*')
.where({user_id: req.userId})
return res.status(200).json(coffees)
});
// ==============================================
// GRINDERS
// ==============================================
const GrinderPostBody = z.object({
name: z.string(),
notes: z.union([z.string(), z.undefined()])
})
type GrinderPostBody = z.infer<typeof GrinderPostBody>
authenticatedRouter.post('/grinder', validateBody(GrinderPostBody), async (req: Request, res: Response) => {
const grinder: GrinderPostBody = req.body
try {
await knex('grinder').insert({
id: knex.fn.uuid(),
user_id: req.userId,
name: grinder.name,
notes: grinder.notes,
})
} catch (error) {
console.error(error)
return res.status(500).send('Internal server error');
}
return res.status(200).send('Great success')
});
authenticatedRouter.get('/grinder', async (req: Request, res: Response) => {
const grinders = await knex('grinder').select('*')
.where({user_id: req.userId})
return res.status(200).json(grinders)
});
// ==============================================
// BREWERS
// ==============================================
const BrewerPostBody = z.object({
name: z.string(),
notes: z.union([z.string(), z.undefined()])
})
type BrewerPostBody = z.infer<typeof BrewerPostBody>
authenticatedRouter.post('/brewer', validateBody(BrewerPostBody), async (req: Request, res: Response) => {
const brewer: BrewerPostBody = req.body
try {
await knex('brewer').insert({
id: knex.fn.uuid(),
user_id: req.userId,
name: brewer.name,
notes: brewer.notes,
})
} catch (error) {
console.error(error)
return res.status(500).send('Internal server error');
}
return res.status(200).send('Great success')
});
authenticatedRouter.get('/brewer', async (req: Request, res: Response) => {
const brewers = await knex('brewer').select('*')
.where({user_id: req.userId})
return res.status(200).json(brewers)
});
// ==============================================
// METHODS
// ==============================================
const MethodPostBody = z.object({
coffeeId: z.uuid(),
coffeeAmountG: z.int(),
grinderId: z.uuid(),
grindSetting: z.string(),
brewerId: z.uuid(),
waterAmountMl: z.int(),
waterTemperatureC: z.int(),
bloomingTimeSec: z.int(),
brewingTimeSec: z.int(),
notes: z.union([z.string(), z.undefined()])
})
type MethodPostBody = z.infer<typeof MethodPostBody>
authenticatedRouter.post('/method', validateBody(MethodPostBody), async (req: Request, res: Response) => {
const method: MethodPostBody = req.body
try {
await knex('method').insert({
id: knex.fn.uuid(),
user_id: req.userId,
coffee_id: method.coffeeId,
coffee_amount_g: method.coffeeAmountG,
grinder_id: method.grinderId,
grind_setting: method.grindSetting,
brewer_id: method.brewerId,
water_amount_ml: method.waterAmountMl,
water_temperature_c: method.waterTemperatureC,
blooming_time_sec: method.bloomingTimeSec,
brewing_time_sec: method.brewingTimeSec,
notes: method.notes,
})
} catch (error) {
console.error(error)
return res.status(500).send('Internal server error');
}
return res.status(200).send('Great success')
});
authenticatedRouter.get('/method', async (req: Request, res: Response) => {
const methods = await knex('method').select('*')
.where({user_id: req.userId})
return res.status(200).json(methods)
});
app.use('/api', authenticatedRouter)
const fallbackRouter = express.Router()
// These 2 relative paths require the server to be run from the 'backend' folder
fallbackRouter.use(express.static('../frontend/dist'));
fallbackRouter.get('/{*fallback}', (_req: Request, res: Response) => {
res.sendFile('./frontend/dist/index.html', { root: '..'});
});
app.use(fallbackRouter)
const PORT = process.env.PORT || 3000;
async function startServer() {
try {
app.listen(PORT, () => {
console.log(
`Server is running on port ${PORT} at http://localhost:${PORT}`
);
});
} catch (error) {
console.error('Error starting server:', error);
}
}
startServer();

25
backend/knexfile.ts Normal file
View file

@ -0,0 +1,25 @@
import path from 'path'
import type { Knex } from 'knex'
// Update with your config settings.
// this `data` folder should be mounted in the docker-compose in production, or mkdired in development
const dbPath = path.join(process.env.DB_PREFIX ?? '../data/', '/database.db')
const baseConfig = {
client: 'better-sqlite3',
connection: {
filename: dbPath,
},
}
const config: { [key: string]: Knex.Config } = {
development: baseConfig,
staging: baseConfig,
production: baseConfig
};
module.exports = config;

View file

@ -0,0 +1,53 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('coffee', (table) => {
table.uuid('id').primary().defaultTo(knex.fn.uuid())
table.uuid('user_id')
table.string('name')
table.integer('roast_level')
table.string('notes')
})
await knex.schema.createTable('grinder', (table) => {
table.uuid('id').primary().defaultTo(knex.fn.uuid())
table.uuid('user_id')
table.string('name')
table.string('notes')
})
await knex.schema.createTable('brewer', (table) => {
table.uuid('id').primary().defaultTo(knex.fn.uuid())
table.uuid('user_id')
table.string('name')
table.string('notes')
})
await knex.schema.createTable('method', (table) => {
table.uuid('id').primary().defaultTo(knex.fn.uuid())
table.uuid('user_id')
table.uuid('coffee_id')
table.foreign('coffee_id').references('coffee.id')
table.integer('coffee_amount_g')
table.uuid('grinder_id')
table.foreign('grinder_id').references('grinder.id')
table.string('grind_setting')
table.uuid('brewer_id')
table.foreign('brewer_id').references('brewer.id')
table.integer('water_amount_ml')
table.integer('water_temperature_c')
table.integer('blooming_time_sec')
table.integer('brewing_time_sec')
table.string('notes')
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable('method')
await knex.schema.dropTable('coffee')
await knex.schema.dropTable('grinder')
await knex.schema.dropTable('brewer')
}

31
backend/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "@kahvi.juustodiilerit.fi/backend",
"private": true,
"version": "0.0.0",
"main": "index.js",
"scripts": {
"build": "tsc -b",
"seed": "pnpx ts-node seeder/seeder.ts",
"dev": "nodemon app.ts",
"start": "node dist/app.js"
},
"keywords": [],
"author": "",
"description": "",
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"dependencies": {
"@kahvi.juustodiilerit.fi/shared": "file:../shared",
"@types/express": "^5.0.6",
"better-sqlite3": "^12.5.0",
"express": "^5.2.1",
"jose": "^6.1.3",
"knex": "^3.1.0",
"nodemon": "^3.1.11",
"sqlite3": "^5.1.7",
"zod": "^4.3.5"
}
}

2166
backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- better-sqlite3
- sqlite3

115
backend/tsconfig.json Normal file
View file

@ -0,0 +1,115 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
"typeRoots": [
"./@types",
"./node_modules/@types"
], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": ["/**/*"],
"exclude": ["node_modules"]
}

4
frontend/.env.example Normal file
View file

@ -0,0 +1,4 @@
VITE_PLATFORM_ROOT=http://localhost:3000
VITE_RAUTHY_CLIENT_ID=kahvi-localhost
VITE_RAUTHY_REDIRECT_URL=http://localhost:5173/

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View file

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>kahvi.juustodiilerit.fi</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
frontend/package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "@kahvi.juustodiilerit.fi/frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@kahvi.juustodiilerit.fi/shared": "file:../shared",
"@tanstack/react-query": "^5.90.16",
"oidc-client-ts": "^3.4.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-oidc-context": "^3.3.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.50.1",
"vite": "^7.3.0"
}
}

2080
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

1
frontend/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View file

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

434
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,434 @@
import { useState } from 'react'
import { useAuth } from "react-oidc-context"
import { useBrewers, useCoffees, useGrinders, useMethods } from './api';
import { queryClient } from './main';
import { environment } from './environment';
import './App.css'
function Home() {
const auth = useAuth();
const token = auth.user?.access_token
const { isPending: areCoffeesPending, data: coffees } = useCoffees(token!)
const { isPending: areGrindersPending, data: grinders } = useGrinders(token!)
const { isPending: areBrewersPending, data: brewers } = useBrewers(token!)
const { isPending: areMethodsPending, data: methods } = useMethods(token!)
const [addCoffeeName, setAddCoffeeName] = useState('')
const [addCoffeeRoastLevel, setAddCoffeeRoastLevel] = useState(3)
const [addCoffeeNotes, setAddCoffeeNotes] = useState('')
const [addGrinderName, setAddGrinderName] = useState('')
const [addGrinderNotes, setAddGrinderNotes] = useState('')
const [addBrewerName, setAddBrewerName] = useState('')
const [addBrewerNotes, setAddBrewerNotes] = useState('')
const [addMethodCoffeeId, setAddMethodCoffeeId] = useState('')
const [addMethodCoffeeAmountG, setAddMethodCoffeeAmountG] = useState(15)
const [addMethodGrinderId, setAddMethodGrinderId] = useState('')
const [addMethodGrindSetting, setAddMethodGrindSetting] = useState('')
const [addMethodBrewerId, setAddMethodBrewerId] = useState('')
const [addMethodWaterAmountMl, setAddMethodWaterAmountMl] = useState(250)
const [addMethodWaterTemperatureC, setAddMethodWaterTemperatureC] = useState(90)
const [addMethodBloomingTimeSec, setAddMethodBloomingTimeSec] = useState(45)
const [addMethodBrewingTimeSec, setAddMethodBrewingTimeSec] = useState(120)
const [addMethodNotes, setAddMethodNotes] = useState('')
const [randomMix, setRandomMix] = useState(
undefined as {coffeeId: string, grinderId: string, brewerId: string} | undefined
)
const [randomMethodId, setRandomMethodId] = useState(undefined as string | undefined)
if (areCoffeesPending || areGrindersPending || areBrewersPending || areMethodsPending) {
return (
<div>
Loading...
</div>
)
}
function openDialog(dialogId: string) {
const dialogElement = document.getElementById(dialogId);
(dialogElement as HTMLDialogElement).showModal()
}
function closeDialog(dialogId: string) {
const dialogElement = document.getElementById(dialogId);
(dialogElement as HTMLDialogElement).close()
}
function closeDialogButton(dialogId: string) {
return <button onClick={closeDialog.bind(null, dialogId)}>Cancel</button>
}
async function postNewEntry(entryType: 'coffee'|'grinder'|'brewer'|'method', body: any) {
try {
let result = await fetch(
`${environment.platformRoot}/api/${entryType}`,
{
method: 'POST',
body: JSON.stringify(body),
headers: {
Authorization: `Bearer ${token}`,
'content-type': 'application/json'
}
}
)
if (result.status !== 200) {
window.alert(`Error: status ${result.status}; ${await result.text()}`)
}
} catch (error) {
window.alert(`Error: ${error}`)
}
}
// ============================================
// COFFEE
// ============================================
const addCoffeeDialogId = 'addCoffeeDialog'
async function postNewCoffee() {
postNewEntry(
'coffee',
{
name: addCoffeeName,
roastLevel: addCoffeeRoastLevel,
notes: addCoffeeNotes,
}
)
queryClient.invalidateQueries({ queryKey: ['coffees'] })
closeDialog(addCoffeeDialogId)
}
function renderAddCoffeeDialog() {
return (
<dialog id={addCoffeeDialogId} className='addSomethingDialog'>
<div className='column'>
<label className="" htmlFor="addCoffeeName">
Name
</label>
<input className="" id="addCoffeeName" name="addCoffeeName" placeholder="Rost Aalto, Fazer Blend..." required type="text" value={addCoffeeName} onChange={(event) => setAddCoffeeName(event.target.value)}/>
<label className="" htmlFor="addCoffeeRoastLevel">
Roast level
</label>
<input className="" id="addCoffeeRoastLevel" name="addCoffeeRoastLevel" placeholder="3" required type="number" value={addCoffeeRoastLevel} onChange={(event) => setAddCoffeeRoastLevel(Number.parseInt(event.target.value))}/>
<label className="" htmlFor="addCoffeeNotes">
Notes
</label>
<input className="" id="addCoffeeNotes" name="addCoffeeNotes" placeholder="Notes" required type="text" value={addCoffeeNotes} onChange={(event) => setAddCoffeeNotes(event.target.value)}/>
<button onClick={postNewCoffee}>
Add coffee
</button>
{closeDialogButton(addCoffeeDialogId)}
</div>
</dialog>
)
}
// ============================================
// GRINDER
// ============================================
const addGrinderDialogId = 'addGrinderDialog'
async function postNewGrinder() {
postNewEntry(
'grinder',
{
name: addGrinderName,
notes: addGrinderNotes,
}
)
queryClient.invalidateQueries({ queryKey: ['grinders'] })
closeDialog(addGrinderDialogId)
}
function renderAddGrinderDialog() {
return (
<dialog id={addGrinderDialogId} className='addSomethingDialog'>
<div className='column'>
<label className="" htmlFor="addGrinderName">
Name
</label>
<input className="" id="addGrinderName" name="addGrinderName" placeholder="Comandante C40" required type="text" value={addGrinderName} onChange={(event) => setAddGrinderName(event.target.value)}/>
<label className="" htmlFor="addGrinderNotes">
Notes
</label>
<input className="" id="addGrinderNotes" name="addGrinderNotes" placeholder="Notes" required type="text" value={addGrinderNotes} onChange={(event) => setAddGrinderNotes(event.target.value)}/>
<button onClick={postNewGrinder}>
Add grinder
</button>
{closeDialogButton(addGrinderDialogId)}
</div>
</dialog>
)
}
// ============================================
// BREWER
// ============================================
const addBrewerDialogId = 'addBrewerDialog'
async function postNewBrewer() {
postNewEntry(
'brewer',
{
name: addBrewerName,
notes: addBrewerNotes,
}
)
queryClient.invalidateQueries({ queryKey: ['brewers'] })
closeDialog(addBrewerDialogId)
}
function renderAddBrewerDialog() {
return (
<dialog id={addBrewerDialogId} className='addSomethingDialog'>
<div className='column'>
<label className="" htmlFor="addBrewerName">
Name
</label>
<input className="" id="addBrewerName" name="addBrewerName" placeholder="Bialetti Moka Express, Hario Switch..." required type="text" value={addBrewerName} onChange={(event) => setAddBrewerName(event.target.value)}/>
<label className="" htmlFor="addBrewerNotes">
Notes
</label>
<input className="" id="addBrewerNotes" name="addBrewerNotes" placeholder="Notes" required type="text" value={addBrewerNotes} onChange={(event) => setAddBrewerNotes(event.target.value)}/>
<button onClick={postNewBrewer}>
Add brewer
</button>
{closeDialogButton(addBrewerDialogId)}
</div>
</dialog>
)
}
// ============================================
// METHOD
// ============================================
const addMethodDialogId = 'addMethodDialog'
async function postNewMethod() {
postNewEntry(
'method',
{
coffeeId: addMethodCoffeeId,
coffeeAmountG: addMethodCoffeeAmountG,
grinderId: addMethodGrinderId,
grindSetting: addMethodGrindSetting,
brewerId: addMethodBrewerId,
waterAmountMl: addMethodWaterAmountMl,
waterTemperatureC: addMethodWaterTemperatureC,
bloomingTimeSec: addMethodBloomingTimeSec,
brewingTimeSec: addMethodBrewingTimeSec,
notes: addMethodNotes,
}
)
queryClient.invalidateQueries({ queryKey: ['methods'] })
closeDialog(addMethodDialogId)
}
function renderAddMethodDialog() {
const coffeeOptions = coffees.map((coffee: any) => <option key={`coffee-option-${coffee.id}`} value={coffee.id}>{coffee.name} - Roast {coffee.roast_level}</option>)
const grinderOptions = grinders.map((grinder: any) => <option key={`grinder-option-${grinder.id}`} value={grinder.id}>{grinder.name}</option>)
const brewerOptions = brewers.map((brewer: any) => <option key={`brewer-option-${brewer.id}`} value={brewer.id}>{brewer.name}</option>)
return (
<dialog id={addMethodDialogId} className='addSomethingDialog'>
<div className='column'>
<label className="" htmlFor="addMethodCoffee">
Coffee
</label>
<select id="addMethodCoffee" name="addMethodCoffee" value={addMethodCoffeeId} onChange={(event) => setAddMethodCoffeeId(event.target.value)}>
<option value="">Select a coffee</option>
{coffeeOptions}
</select>
<label className="" htmlFor="addMethodCoffeeAmountG">
Coffee amount (grams)
</label>
<input className="" id="addMethodCoffeeAmountG" name="addMethodCoffeeAmountG" placeholder="15" required type="number" value={addMethodCoffeeAmountG} onChange={(event) => setAddMethodCoffeeAmountG(Number.parseInt(event.target.value))}/>
<label className="" htmlFor="addMethodGrinder">
Grinder
</label>
<select id="addMethodGrinder" name="addMethodGrinder" value={addMethodGrinderId} onChange={(event) => setAddMethodGrinderId(event.target.value)}>
<option value="">Select a grinder</option>
{grinderOptions}
</select>
<label className="" htmlFor="addMethodGrindSetting">
Grind setting
</label>
<input className="" id="addMethodGrindSetting" name="addMethodGrindSetting" placeholder="Clicks" required type="text" value={addMethodGrindSetting} onChange={(event) => setAddMethodGrindSetting(event.target.value)}/>
<label className="" htmlFor="addMethodBrewer">
Brewer
</label>
<select id="addMethodBrewer" name="addMethodBrewer" value={addMethodBrewerId} onChange={(event) => setAddMethodBrewerId(event.target.value)}>
<option value="">Select a brewer</option>
{brewerOptions}
</select>
<label className="" htmlFor="addMethodWaterAmountMl">
Water amount (ml)
</label>
<input className="" id="addMethodWaterAmountMl" name="addMethodWaterAmountMl" placeholder="250" required type="number" value={addMethodWaterAmountMl} onChange={(event) => setAddMethodWaterAmountMl(Number.parseInt(event.target.value))}/>
<label className="" htmlFor="addMethodWaterTemperatureC">
Water temperature (°C)
</label>
<input className="" id="addMethodWaterTemperatureC" name="addMethodWaterTemperatureC" placeholder="250" required type="number" value={addMethodWaterTemperatureC} onChange={(event) => setAddMethodWaterTemperatureC(Number.parseInt(event.target.value))}/>
<label className="" htmlFor="addMethodBloomingTimeSec">
Bloom time (sec)
</label>
<input className="" id="addMethodBloomingTimeSec" name="addMethodBloomingTimeSec" placeholder="45" required type="number" value={addMethodBloomingTimeSec} onChange={(event) => setAddMethodBloomingTimeSec(Number.parseInt(event.target.value))}/>
<label className="" htmlFor="addMethodBrewingTimeSec">
Brew time (sec)
</label>
<input className="" id="addMethodBrewingTimeSec" name="addMethodBrewingTimeSec" placeholder="120" required type="number" value={addMethodBrewingTimeSec} onChange={(event) => setAddMethodBrewingTimeSec(Number.parseInt(event.target.value))}/>
<label className="" htmlFor="addMethodNotes">
Notes
</label>
<input className="" id="addMethodNotes" name="addMethodNotes" placeholder="Notes" required type="text" value={addMethodNotes} onChange={(event) => setAddMethodNotes(event.target.value)}/>
<button onClick={postNewMethod}>
Add method
</button>
{closeDialogButton(addMethodDialogId)}
</div>
</dialog>
)
}
return (
<div className='column'>
<div className='mixOptions'>
{renderAddCoffeeDialog()}
<button className='coffees' onClick={openDialog.bind(null, addCoffeeDialogId)}>
Add coffee
</button>
{coffees?.map((coffee: any) => <p key={`coffee-${coffee.id}`} className={`coffees ${coffee.id === randomMix?.coffeeId ? 'randomlySelected' : ''}`}>{coffee.name} - Roast {coffee.roast_level}</p>)}
{renderAddGrinderDialog()}
<button className='grinders' onClick={openDialog.bind(null, addGrinderDialogId)}>
Add grinder
</button>
{grinders?.map((grinder: any) => <p key={`grinder-${grinder.id}`} className={`grinders ${grinder.id === randomMix?.grinderId ? 'randomlySelected' : ''}`}>{grinder.name}</p>)}
{renderAddBrewerDialog()}
<button className='brewers' onClick={openDialog.bind(null, addBrewerDialogId)}>
Add brewer
</button>
{brewers?.map((brewer: any) => <p key={`brewer-${brewer.id}`} className={`brewers ${brewer.id === randomMix?.brewerId ? 'randomlySelected' : ''}`}>{brewer.name}</p>)}
</div>
<div className='row'>
</div>
<div className='column methods'>
{renderAddMethodDialog()}
<button onClick={openDialog.bind(null, addMethodDialogId)}>
Add method
</button>
<table className='methods'>
<tbody>
{methods?.map((method: any) => {
const coffeeFound = coffees.find((coffee: any) => coffee.id === method.coffee_id)
const grinderFound = grinders.find((grinder: any) => grinder.id === method.grinder_id)
const brewerFound = brewers.find((brewer: any) => brewer.id === method.brewer_id)
const isRandomlySelected = (randomMethodId && method.id === randomMethodId)
|| (randomMix && method.coffee_id === randomMix?.coffeeId && method.grinder_id === randomMix?.grinderId && method.brewer_id === randomMix?.brewerId)
return (
<tr key={`method-${method.id}`} className={`${isRandomlySelected ? 'randomlySelected' : ''}`}>
<td>{coffeeFound.name}</td>
<td>{method.coffee_amount_g} g</td>
<td>{grinderFound.name}</td>
<td>{method.grind_setting}</td>
<td>{brewerFound.name}</td>
<td>{method.water_amount_ml} ml</td>
<td>{method.water_temperature_c} °C</td>
<td>Bloom {method.blooming_time_sec} sec</td>
<td>Brew {method.brewing_time_sec} sec</td>
</tr>
)
})}
</tbody>
</table>
<div className="row randomizerButtons">
<button onClick={() => {
setRandomMethodId(undefined)
const coffeeIndex = Math.floor(Math.random() * coffees.length)
const grinderIndex = Math.floor(Math.random() * grinders.length)
const brewerIndex = Math.floor(Math.random() * brewers.length)
setRandomMix({
coffeeId: coffees[coffeeIndex]?.id,
grinderId: grinders[grinderIndex]?.id,
brewerId: brewers[brewerIndex]?.id
})
}}>
Select a random mix
</button>
<button onClick={() => {
setRandomMix(undefined)
const randomIndex = Math.floor(Math.random() * methods.length)
setRandomMethodId(methods[randomIndex]?.id)
}}>
Select a random method
</button>
</div>
</div>
</div>
)
}
function App() {
const auth = useAuth();
switch (auth.activeNavigator) {
case "signinSilent":
return <div>Signing you in...</div>;
case "signoutRedirect":
return <div>Signing you out...</div>;
}
if (auth.isLoading) {
return <div>Loading...</div>;
}
if (auth.error) {
return <div>Oops... {auth.error.message}</div>;
}
if (auth.isAuthenticated) {
if (document.location.search.includes('code=') && document.location.search.includes('state=')) {
document.location.href = '/'
return
}
return (
<div className='layout'>
<nav className='row'>
<h1>kahvi.juustodiilerit.fi</h1>
<button onClick={() => void auth.removeUser()}>Log out</button>
</nav>
<main className="column">
<Home/>
</main>
</div>
);
}
return (
<div className='layout'>
<nav className='row'>
<h1>kahvi.juustodiilerit.fi</h1>
<button onClick={() => void auth.signinRedirect()}>Log in</button>
</nav>
<main className="column">
<button onClick={() => void auth.signinRedirect()}>Log in</button>
</main>
</div>
)
}
export default App

65
frontend/src/api.ts Normal file
View file

@ -0,0 +1,65 @@
import {
useQuery,
} from '@tanstack/react-query'
import { environment } from './environment'
export const useCoffees = (token: string) => useQuery({
queryKey: ['coffees'],
queryFn: async () =>
fetch(
`${environment.platformRoot}/api/coffee`,
{
headers: {
Authorization: `Bearer ${token}`,
}
}
).then((res) =>
res.json(),
)
})
export const useGrinders = (token: string) => useQuery({
queryKey: ['grinders'],
queryFn: async () =>
fetch(
`${environment.platformRoot}/api/grinder`,
{
headers: {
Authorization: `Bearer ${token}`,
}
}
).then((res) =>
res.json(),
)
})
export const useBrewers = (token: string) => useQuery({
queryKey: ['brewers'],
queryFn: async () =>
fetch(
`${environment.platformRoot}/api/brewer`,
{
headers: {
Authorization: `Bearer ${token}`,
}
}
).then((res) =>
res.json(),
)
})
export const useMethods = (token: string) => useQuery({
queryKey: ['methods'],
queryFn: async () =>
fetch(
`${environment.platformRoot}/api/method`,
{
headers: {
Authorization: `Bearer ${token}`,
}
}
).then((res) =>
res.json(),
)
})

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,8 @@
export const environment = {
platformRoot: import.meta.env.VITE_PLATFORM_ROOT,
rauthy: {
clientId: import.meta.env.VITE_RAUTHY_CLIENT_ID,
redirectUrl: import.meta.env.VITE_RAUTHY_REDIRECT_URL,
},
}

190
frontend/src/index.css Normal file
View file

@ -0,0 +1,190 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #e9e9e9;
}
}
#root {
padding: 0;
min-height: 100vh;
min-width: 100vw;
max-width: 100vw;
}
.layout {
padding: 0;
min-height: 100vh;
min-width: 100vw;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
}
nav h1 {
margin: 0;
font-size: 16px;
}
main {
flex-grow: 1;
align-self: stretch;
display: flex;
justify-content: space-evenly;
align-items: center;
}
.row {
display: flex;
flex-direction: row;
gap: 8px;
}
.column {
display: flex;
flex-direction: column;
gap: 8px;
}
.mixOptions {
display: grid;
grid-gap: 8px;
grid-template-columns: repeat(auto-fit, 300px);
grid-auto-flow: column;
}
.coffees, .grinders, .brewers {
/* display: grid; */
/* grid-template-columns: subgrid; */
width: 100%;
max-width: 300px;
}
.coffees {
grid-column: 1;
}
.grinders {
grid-column: 2;
}
.brewers {
grid-column: 3;
}
button.coffees, button.grinders, button.brewers {
margin: 0;
grid-row: 1;
}
p.coffees, p.grinders, p.brewers {
margin: 0;
}
.methods {
max-width: 1200px;
}
.randomlySelected {
outline: gold solid 2px!important;
}
.randomlySelected td {
outline: gold solid 1px !important;
}
@media (prefers-color-scheme: light) {
.randomlySelected {
background: gold;
}
}
.randomizerButtons {
margin-top: 16px;
}
.randomizerButtons button {
flex-grow: 1;
}
.addSomethingDialog {
}
table.methods {
border-collapse: collapse;
}
table.methods td {
outline: #777777 1px solid;
padding: 2px 8px;
}

31
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,31 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { AuthProvider } from 'react-oidc-context'
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import App from './App.tsx'
import './index.css'
import { environment } from './environment.ts'
export const queryClient = new QueryClient()
const oidcConfig = {
authority: 'https://rauthy.juustodiilerit.fi',
client_id: environment.rauthy.clientId,
redirect_uri: environment.rauthy.redirectUrl,
// ...
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<AuthProvider {...oidcConfig}>
<App />
</AuthProvider>
</QueryClientProvider>
</StrictMode>,
)

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "kahvi.juustodiilerit.fi",
"version": "0.0.0",
"description": "",
"main": "index.js",
"scripts": {
"install:all": "pnpm install && (cd ./shared && pnpm install) && (cd ./frontend && pnpm install) && (cd ./backend && pnpm install)",
"build:shared": "cd ./shared && pnpm run build",
"build:frontend": "cd ./frontend && pnpm run build",
"build:backend": "cd ./backend && pnpm run build",
"build": "pnpm run build:shared && pnpm run build:frontend && pnpm run build:backend",
"start": "cd ./backend && pnpm run start",
"dev": "concurrently \"cd ./shared && pnpm run dev\" \"cd ./backend && pnpm run dev\" \"cd ./frontend && pnpm run dev\""
},
"keywords": [],
"author": "",
"dependencies": {
"concurrently": "^9.2.1"
}
}

206
pnpm-lock.yaml generated Normal file
View file

@ -0,0 +1,206 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
concurrently:
specifier: ^9.2.1
version: 9.2.1
packages:
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
concurrently@9.2.1:
resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
engines: {node: '>=18'}
hasBin: true
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
snapshots:
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
concurrently@9.2.1:
dependencies:
chalk: 4.1.2
rxjs: 7.8.2
shell-quote: 1.8.3
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.7.2
emoji-regex@8.0.0: {}
escalade@3.2.0: {}
get-caller-file@2.0.5: {}
has-flag@4.0.0: {}
is-fullwidth-code-point@3.0.0: {}
require-directory@2.1.1: {}
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
shell-quote@1.8.3: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
tree-kill@1.2.2: {}
tslib@2.8.1: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
y18n@5.0.8: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1

34
shared/package-lock.json generated Normal file
View file

@ -0,0 +1,34 @@
{
"name": "@kahvi.juustodiilerit.fi/shared",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@kahvi.juustodiilerit.fi/shared",
"version": "0.0.0",
"devDependencies": {
"typescript": "^5.9.3"
}
},
"node_modules/.pnpm/typescript@5.9.3": {
"dev": true
},
"node_modules/.pnpm/typescript@5.9.3/node_modules/typescript": {
"version": "5.9.3",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/typescript": {
"resolved": "node_modules/.pnpm/typescript@5.9.3/node_modules/typescript",
"link": true
}
}
}

16
shared/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "@kahvi.juustodiilerit.fi/shared",
"version": "0.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -b",
"dev": "tsc -w"
},
"keywords": [],
"author": "",
"description": "",
"devDependencies": {
"typescript": "^5.9.3"
}
}

24
shared/pnpm-lock.yaml generated Normal file
View file

@ -0,0 +1,24 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
typescript:
specifier: ^5.9.3
version: 5.9.3
packages:
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
snapshots:
typescript@5.9.3: {}

0
shared/src/index.ts Normal file
View file

14
shared/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"moduleResolution": "Node",
"module": "CommonJS",
"target": "es6",
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"lib": ["ES2021"],
"outDir": "dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}