First usable version
This commit is contained in:
parent
1ae1539ee0
commit
7d3ca6c2c6
38 changed files with 6070 additions and 0 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
dist
|
||||
data
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
|||
|
||||
data
|
||||
target
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
|
|
|
|||
26
Dockerfile
Normal file
26
Dockerfile
Normal 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
2
backend/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
VITE_RAUTHY_CLIENT_ID=kahvi-localhost
|
||||
5
backend/@types/express/index.d.ts
vendored
Normal file
5
backend/@types/express/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
declare namespace Express{
|
||||
interface Request{
|
||||
userId: string | undefined
|
||||
}
|
||||
}
|
||||
229
backend/app.ts
Normal file
229
backend/app.ts
Normal 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
25
backend/knexfile.ts
Normal 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;
|
||||
53
backend/migrations/20251223131938_initialize_db.ts
Normal file
53
backend/migrations/20251223131938_initialize_db.ts
Normal 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
31
backend/package.json
Normal 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
2166
backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
3
backend/pnpm-workspace.yaml
Normal file
3
backend/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- sqlite3
|
||||
115
backend/tsconfig.json
Normal file
115
backend/tsconfig.json
Normal 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
4
frontend/.env.example
Normal 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
24
frontend/.gitignore
vendored
Normal 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
73
frontend/README.md
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
34
frontend/package.json
Normal 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
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
1
frontend/public/vite.svg
Normal 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
42
frontend/src/App.css
Normal 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
434
frontend/src/App.tsx
Normal 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
65
frontend/src/api.ts
Normal 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(),
|
||||
)
|
||||
})
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
8
frontend/src/environment.ts
Normal file
8
frontend/src/environment.ts
Normal 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
190
frontend/src/index.css
Normal 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
31
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
7
frontend/vite.config.ts
Normal 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
20
package.json
Normal 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
206
pnpm-lock.yaml
generated
Normal 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
34
shared/package-lock.json
generated
Normal 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
16
shared/package.json
Normal 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
24
shared/pnpm-lock.yaml
generated
Normal 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
0
shared/src/index.ts
Normal file
14
shared/tsconfig.json
Normal file
14
shared/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue