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 (
+
+
+
+ );
+};
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"],