diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 6bd9c8f..e43b297 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -4,7 +4,8 @@ import { RequestError } from "./RequestError"; import { Database } from "./services/database"; import { Request } from "./services/request"; import { brotliCompress } from "node:zlib"; -import { AccessLog } from "common"; +import { AccessLog } from "common/db"; +import { LoginRequest, LoginResponse } from "common/api"; const match = (method: string, ...pattern: readonly string[]) => Effect.gen(function* () { @@ -82,10 +83,7 @@ export const app = pipe( if (yield* match("POST", "login")) { - const body = yield* requestJson(S.Struct({ - username: S.NonEmptyString, - password: S.NonEmptyString, - })); + const body = yield* requestJson(LoginRequest); const user = yield* pipe( db.getUserByUsername(body.username), @@ -99,12 +97,17 @@ export const app = pipe( const sessionId = yield* db.createSession(user.userId); - const responseData = { + const responseData = LoginResponse.make({ userId: user.userId, username: user.username, admin: user.admin, - }; - const responseJson = JSON.stringify(responseData); + }); + + const responseJson = yield* pipe( + responseData, + S.encode(LoginResponse), + Effect.map(JSON.stringify), + ); const responseArray = new TextEncoder().encode(responseJson); const expiresAt = DateTime.addDuration(yield* DateTime.now, Duration.days(7)); diff --git a/packages/common/src/api.ts b/packages/common/src/api.ts new file mode 100644 index 0000000..9a3e536 --- /dev/null +++ b/packages/common/src/api.ts @@ -0,0 +1,13 @@ +import { Schema as S } from "@effect/schema"; +import { UserId } from "common"; + +export const LoginRequest = S.Struct({ + username: S.NonEmptyString, + password: S.NonEmptyString, +}); + +export const LoginResponse = S.Struct({ + userId: UserId, + username: S.NonEmptyString, + admin: S.Boolean, +}); diff --git a/packages/common/src/db.ts b/packages/common/src/db.ts new file mode 100644 index 0000000..5c04854 --- /dev/null +++ b/packages/common/src/db.ts @@ -0,0 +1,67 @@ +import { Schema as S } from "@effect/schema"; +import { AttachmentId, BooleanFromNumber, PieceId, RequestId, SessionId, Sha256, UserId } from "common"; +import { Brand as B, pipe } from "effect"; + +export const SystemInformation = S.Struct({ + createdBy: S.Union(UserId, S.Null), + createdAt: S.DateTimeUtc, + modifiedBy: S.Union(UserId, S.Null), + modifiedAt: S.DateTimeUtc, +}); + +export type SystemInformation = typeof SystemInformation.Type; + +// --- TABLES ------------------------------------------------------------------ + +export const AccessLog = S.Struct({ + timestamp: S.DateTimeUtc, + requestId: RequestId, + method: S.NonEmptyString, + pathname: S.NonEmptyString, + query: S.parseJson(S.Record({ + key: S.String, + value: S.String, + })), + ip: S.Union(S.NonEmptyString, S.Null), +}); + +export const Attachment = pipe( + S.Struct({ + attachmentId: AttachmentId, + pieceId: PieceId, + sha256: Sha256, + filename: S.NonEmptyString, + mediaType: S.NonEmptyString, + }), + S.extend(SystemInformation), +); + +export const Piece = pipe( + S.Struct({ + pieceId: PieceId, + name: S.NonEmptyString, + composer: S.Union(S.NonEmptyString, S.Null), + lyricist: S.Union(S.NonEmptyString, S.Null), + arranger: S.Union(S.NonEmptyString, S.Null), + }), + S.extend(SystemInformation), +); + +export const Session = S.Struct({ + sessionId: SessionId, + userId: UserId, + expiresAt: S.DateTimeUtc, +}); + +export const User = S.Struct({ + userId: UserId, + username: S.NonEmptyString, + password: S.NonEmptyString, + admin: BooleanFromNumber, +}); + +export type AccessLog = typeof AccessLog.Type; +export type Attachment = typeof Attachment.Type; +export type Piece = typeof Piece.Type; +export type Session = typeof Session.Type; +export type User = typeof User.Type; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 4d10e84..5edd3ae 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -20,7 +20,7 @@ export const Sha256 = pipe( (array) => array.byteLength === 32, () => B.error(`Expected Uint8Array to be 32 bytes long`), )), -); +).annotations({ identifier: "SHA-256" }); export class BooleanFromNumber extends S.transform( S.Number, @@ -31,67 +31,3 @@ export class BooleanFromNumber extends S.transform( encode: (i) => i ? 1 : 0, }, ).annotations({ identifier: "BooleanFromNumber" }) { } - -export const SystemInformation = S.Struct({ - createdBy: S.Union(UserId, S.Null), - createdAt: S.DateTimeUtc, - modifiedBy: S.Union(UserId, S.Null), - modifiedAt: S.DateTimeUtc, -}); - -export type SystemInformation = typeof SystemInformation.Type; - -// --- TABLES ------------------------------------------------------------------ - -export const AccessLog = S.Struct({ - timestamp: S.DateTimeUtc, - requestId: RequestId, - method: S.NonEmptyString, - pathname: S.NonEmptyString, - query: S.parseJson(S.Record({ - key: S.String, - value: S.String, - })), - ip: S.Union(S.NonEmptyString, S.Null), -}); - -export const Attachment = pipe( - S.Struct({ - attachmentId: AttachmentId, - pieceId: PieceId, - sha256: Sha256, - filename: S.NonEmptyString, - mediaType: S.NonEmptyString, - }), - S.extend(SystemInformation), -); - -export const Piece = pipe( - S.Struct({ - pieceId: PieceId, - name: S.NonEmptyString, - composer: S.Union(S.NonEmptyString, S.Null), - lyricist: S.Union(S.NonEmptyString, S.Null), - arranger: S.Union(S.NonEmptyString, S.Null), - }), - S.extend(SystemInformation), -); - -export const Session = S.Struct({ - sessionId: SessionId, - userId: UserId, - expiresAt: S.DateTimeUtc, -}); - -export const User = S.Struct({ - userId: UserId, - username: S.NonEmptyString, - password: S.NonEmptyString, - admin: BooleanFromNumber, -}); - -export type AccessLog = typeof AccessLog.Type; -export type Attachment = typeof Attachment.Type; -export type Piece = typeof Piece.Type; -export type Session = typeof Session.Type; -export type User = typeof User.Type; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index d157f75..c188506 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -13,6 +13,7 @@ "@effect/schema": "catalog:", "effect": "catalog:", "fast-check": "catalog:", - "preact": "catalog:" + "preact": "catalog:", + "preact-iso": "catalog:" } } diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx new file mode 100644 index 0000000..366018d --- /dev/null +++ b/packages/frontend/src/App.tsx @@ -0,0 +1,15 @@ +import { ErrorBoundary, lazy, LocationProvider, Route, Router } from "preact-iso"; + +const Home = lazy(() => import("./routes/Home")); +const Login = lazy(() => import("./routes/Login")); + +export const App = () => ( + + + + + + + + +); diff --git a/packages/frontend/src/index.css.ts b/packages/frontend/src/index.css.ts index bf69f16..61eb243 100644 --- a/packages/frontend/src/index.css.ts +++ b/packages/frontend/src/index.css.ts @@ -4,6 +4,10 @@ globalStyle("html, body", { margin: 0, padding: 0, + width: "100%", + height: "100%", + overflow: "hidden", + backgroundColor: "white", color: "black", @@ -13,7 +17,26 @@ globalStyle("html, body", { "@media": { "(prefers-color-scheme: dark)": { - backgroundColor: "black", + backgroundColor: "#1E1E1E", + color: "white", + }, + }, +}); + +globalStyle("input, button, select, textarea", { + border: "none", + outline: "none", + backgroundImage: "none", + backgroundColor: "transparent", + + fontFamily: "system-ui, sans-serif", + fontSize: 16, + fontWeight: "normal", + + color: "black", + + "@media": { + "(prefers-color-scheme: dark)": { color: "white", }, }, diff --git a/packages/frontend/src/index.tsx b/packages/frontend/src/index.tsx index a801c7e..ed53a05 100644 --- a/packages/frontend/src/index.tsx +++ b/packages/frontend/src/index.tsx @@ -1,5 +1,6 @@ import { render } from "preact"; +import { App } from "./App"; import "./index.css"; -render(

Hello World

, document.body); +render(, document.body); diff --git a/packages/frontend/src/routes/Home.tsx b/packages/frontend/src/routes/Home.tsx new file mode 100644 index 0000000..461f67a --- /dev/null +++ b/packages/frontend/src/routes/Home.tsx @@ -0,0 +1 @@ +export default () => null; diff --git a/packages/frontend/src/routes/Login.css.ts b/packages/frontend/src/routes/Login.css.ts new file mode 100644 index 0000000..e4b101e --- /dev/null +++ b/packages/frontend/src/routes/Login.css.ts @@ -0,0 +1,138 @@ +import { style } from "@vanilla-extract/css"; + +export const container = style({ + width: "100%", + height: "100%", + + display: "flex", + alignItems: "center", + justifyContent: "center", +}); + +export const box = style({ + padding: 8, + + display: "flex", + flexDirection: "column", + gap: 8, + + borderWidth: 1, + borderStyle: "solid", + borderColor: "black", + + borderRadius: 4, + + "@media": { + "(prefers-color-scheme: dark)": { + borderColor: "white", + }, + }, +}); + +export const header = style({ + paddingBottom: 8, + + borderBottomWidth: 1, + borderBottomStyle: "solid", + borderBottomColor: "black", + + textAlign: "center", + fontWeight: "bold", + + "@media": { + "(prefers-color-scheme: dark)": { + borderBottomColor: "white", + }, + }, +}); + +export const input = style({ + width: "32ch", + padding: 8, + + borderWidth: 1, + borderStyle: "solid", + borderColor: "black", + + borderRadius: 4, + + "selectors": { + "&:focus": { + outlineWidth: 2, + outlineStyle: "solid", + outlineColor: "#8080FF", + + "@media": { + "(prefers-color-scheme: dark)": { + outlineColor: "#C0C0FF", + }, + }, + }, + }, + + "@media": { + "(prefers-color-scheme: dark)": { + borderColor: "white", + }, + }, +}); + +export const submit = style({ + padding: 8, + + backgroundColor: "#C0C0C0", + + borderWidth: 2, + borderStyle: "solid", + borderTopColor: "#E0E0E0", + borderLeftColor: "#E0E0E0", + borderRightColor: "#404040", + borderBottomColor: "#404040", + + borderRadius: 4, + + cursor: "pointer", + + "selectors": { + "&:focus": { + outlineWidth: 2, + outlineStyle: "solid", + outlineColor: "#8080FF", + + "@media": { + "(prefers-color-scheme: dark)": { + outlineColor: "#C0C0FF", + }, + }, + }, + + "&:active": { + borderTopColor: "#404040", + borderLeftColor: "#404040", + borderRightColor: "#E0E0E0", + borderBottomColor: "#E0E0E0", + + "@media": { + "(prefers-color-scheme: dark)": { + outlineColor: "#C0C0FF", + + borderTopColor: "#202020", + borderLeftColor: "#202020", + borderRightColor: "#606060", + borderBottomColor: "#606060", + }, + }, + }, + }, + + "@media": { + "(prefers-color-scheme: dark)": { + backgroundColor: "#404040", + + borderTopColor: "#606060", + borderLeftColor: "#606060", + borderRightColor: "#202020", + borderBottomColor: "#202020", + }, + }, +}); diff --git a/packages/frontend/src/routes/Login.tsx b/packages/frontend/src/routes/Login.tsx new file mode 100644 index 0000000..bec7f9f --- /dev/null +++ b/packages/frontend/src/routes/Login.tsx @@ -0,0 +1,92 @@ +import { Schema as S } from "@effect/schema"; +import { LoginRequest, LoginResponse } from "common/api"; +import { useId, useMemo, useRef } from "preact/hooks"; +import { useStore } from "../store"; +import * as style from "./Login.css"; +import { Effect, Fiber, pipe } from "effect"; + +export default () => { + + const loginUsername = useStore(state => state.loginUsername); + const loginPassword = useStore(state => state.loginPassword); + + const setLoginUsername = useStore(state => state.setLoginUsername); + const setLoginPassword = useStore(state => state.setLoginPassword); + + const usernameId = useId(); + const passwordId = useId(); + + const requestFiber = useRef | null>(null); + const requestEffect = useMemo(() => Effect.gen(function* () { + const requestData = LoginRequest.make(LoginRequest.make({ username: loginUsername, password: loginPassword })); + + const requestJson = yield* pipe( + requestData, + S.encode(LoginRequest), + Effect.map(JSON.stringify), + Effect.orDie, + ); + + const res = yield* Effect.promise((signal) => fetch("/api/login", { + method: "POST", + body: requestJson, + headers: { "Content-Type": "application/json" }, + signal, + })); + + if (!res.ok) { + yield* Effect.die(new Error("Response was not ok")); + } + + const responseData = yield* pipe( + Effect.promise(() => res.json()), + Effect.flatMap(S.decodeUnknown(LoginResponse)), + Effect.orDie, + ); + + setLoginUsername(""); + setLoginPassword(""); + + const a = document.createElement("a"); + a.href = "/"; + a.click(); + }), [loginUsername, loginPassword]); + + const onSubmit = (e: SubmitEvent) => { + e.preventDefault(); + + if (requestFiber.current !== null) { + Effect.runFork(Fiber.interrupt(requestFiber.current), { immediate: true }); + } + + requestFiber.current = Effect.runFork(requestEffect); + }; + + return ( +
+
+
Repozytorium muzyczne
+ + setLoginUsername(e.currentTarget.value)} + /> + + setLoginPassword(e.currentTarget.value)} + /> + +
+
+ ); +}; diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 897c25e..7306f71 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -5,11 +5,19 @@ export type Update = T | ((prev: T) => T); export type Updater = (action: Update) => void; export interface Store { - readonly count: number; + readonly loginUsername: string; + readonly loginPassword: string; + + readonly setLoginUsername: Updater; + readonly setLoginPassword: Updater; } let store: Store = Object.freeze({ - count: 0, + loginUsername: "", + loginPassword: "", + + setLoginUsername: (action) => set(({ loginUsername }) => ({ loginUsername: typeof action === "function" ? action(loginUsername) : loginUsername })), + setLoginPassword: (action) => set(({ loginPassword }) => ({ loginPassword: typeof action === "function" ? action(loginPassword) : loginPassword })), }); // --- STORE IMPLEMENTATION ---------------------------------------------------- diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json index f52a4d1..6157f4a 100644 --- a/packages/frontend/tsconfig.json +++ b/packages/frontend/tsconfig.json @@ -1,3 +1,6 @@ { "extends": "../../tsconfig.base.json", + "references": [ + { "path": "../common" }, + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc6baee..0e0bc32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ catalogs: preact: specifier: ^10.23.1 version: 10.23.1 + preact-iso: + specifier: ^2.6.3 + version: 2.6.3 typescript: specifier: ^5.6.0-beta version: 5.6.0-dev.20240802 @@ -94,6 +97,9 @@ importers: preact: specifier: 'catalog:' version: 10.23.1 + preact-iso: + specifier: 'catalog:' + version: 2.6.3(preact-render-to-string@6.5.7(preact@10.23.1))(preact@10.23.1) devDependencies: '@vanilla-extract/css': specifier: 'catalog:' @@ -664,6 +670,17 @@ packages: resolution: {integrity: sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==} engines: {node: ^10 || ^12 || >=14} + preact-iso@2.6.3: + resolution: {integrity: sha512-2JqNi+Elyt7rf0kULtMn/QskbZE10cDMrhmoanGKWJX3gOCTIfLt/ta5xWu+kQUsyInPkTQIVp3fS5Im0V+X8A==} + peerDependencies: + preact: '>=10' + preact-render-to-string: '>=6.4.0' + + preact-render-to-string@6.5.7: + resolution: {integrity: sha512-nACZDdv/ZZciuldVYMcfGqr61DKJeaAfPx96hn6OXoBGhgtU2yGQkA0EpTzWH4SvnwF0syLsL4WK7AIp3Ruc1g==} + peerDependencies: + preact: '>=10' + preact@10.23.1: resolution: {integrity: sha512-O5UdRsNh4vdZaTieWe3XOgSpdMAmkIYBCT3VhQDlKrzyCm8lUYsk0fmVEvoQQifoOjFRTaHZO69ylrzTW2BH+A==} @@ -1279,6 +1296,15 @@ snapshots: picocolors: 1.0.1 source-map-js: 1.2.0 + preact-iso@2.6.3(preact-render-to-string@6.5.7(preact@10.23.1))(preact@10.23.1): + dependencies: + preact: 10.23.1 + preact-render-to-string: 6.5.7(preact@10.23.1) + + preact-render-to-string@6.5.7(preact@10.23.1): + dependencies: + preact: 10.23.1 + preact@10.23.1: {} pure-rand@6.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index de1331e..be682c8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ catalog: effect: '^3.6.0' fast-check: '^3.20.0' preact: '^10.23.1' + preact-iso: '^2.6.3' typescript: '^5.6.0-beta' ulid: '^2.3.0' vite: '^5.3.5' diff --git a/tsconfig.base.json b/tsconfig.base.json index 3ca2c6c..49fb7d0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,8 @@ "paths": { "common": ["./packages/common/src/index.ts"], + "common/api": ["./packages/common/src/api.ts"], + "common/db": ["./packages/common/src/db.ts"], }, }, "include": ["${configDir}/src"],