From 552f4444d2e83de9a1c1c07d33ff24bcfabdbd96 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 27 Jun 2026 12:47:38 +0200 Subject: [PATCH 01/58] Update dependencies --- package.json | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 8d2366ce..6e862911 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "author": "Josemi Juanes ", "bugs": "https://github.com/jmjuanes/folio/issues", "workspaces": [ + "folio-app", "folio-react", - "apps/*", "workers/*" ], "scripts": { @@ -56,38 +56,38 @@ "react-dom": "^18.2.0" }, "devDependencies": { - "@babel/core": "^7.25.7", - "@babel/plugin-transform-runtime": "^7.25.7", - "@babel/preset-env": "^7.25.7", - "@babel/preset-react": "^7.25.7", - "@babel/preset-typescript": "^7.27.1", + "@babel/core": "^7.29.7", + "@babel/plugin-transform-runtime": "^7.29.7", + "@babel/preset-env": "^7.29.7", + "@babel/preset-react": "^7.29.7", + "@babel/preset-typescript": "^7.29.7", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", - "@types/react": "^19.2.14", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "babel-jest": "^30.0.0", - "babel-loader": "^10.0.0", + "babel-jest": "^30.4.1", + "babel-loader": "^10.1.1", "clean-css": "^5.3.3", - "copy-webpack-plugin": "^13.0.0", + "copy-webpack-plugin": "^14.0.0", "cross-env": "^10.1.0", "highlight.js": "^11.11.1", - "html-webpack-plugin": "^5.6.3", - "jest": "^30.0.0", - "jest-environment-jsdom": "^30.0.0", + "html-webpack-plugin": "^5.6.7", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", "mikel": "^0.38.1", "mikel-cli": "^0.38.1", "mikel-frontmatter": "^0.38.1", "mikel-markdown": "^0.38.1", "mikel-press": "^0.38.1", - "picomatch": "^4.0.2", - "purgecss": "^7.0.2", + "picomatch": "^4.0.4", + "purgecss": "^8.0.0", "react-test-renderer": "^18.2.0", "serve": "^14.2.4", - "webpack": "^5.95.0", - "webpack-cli": "^6.0.1" + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3" }, "license": "MIT" } From 95d2d30bb35c743e2f486152bf62575e218b01e1 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 27 Jun 2026 13:25:58 +0200 Subject: [PATCH 02/58] Update dependencies --- folio-app/package.json | 27 +++++++++++++++++++++++++++ package.json | 12 ++++++------ workers/cloud/package.json | 4 ++-- 3 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 folio-app/package.json diff --git a/folio-app/package.json b/folio-app/package.json new file mode 100644 index 00000000..94f34a7b --- /dev/null +++ b/folio-app/package.json @@ -0,0 +1,27 @@ +{ + "name": "folio-app", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "yarn clean && cross-env NODE_ENV=production webpack build", + "clean": "rm -rf www", + "dev": "yarn clean && webpack serve", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@josemi-icons/react": "^0.7.0", + "classnames": "^2.5.1", + "idb-keyval": "^6.2.5", + "lowcss": "^0.33.0", + "lowcss-forms": "^0.33.0", + "lowcss-helpers": "^0.33.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "html-webpack-plugin": "^5.6.7", + "webpack-dev-server": "^5.2.4" + }, + "license": "MIT" +} \ No newline at end of file diff --git a/package.json b/package.json index 6e862911..33525d3f 100644 --- a/package.json +++ b/package.json @@ -56,11 +56,11 @@ "react-dom": "^18.2.0" }, "devDependencies": { - "@babel/core": "^7.29.7", - "@babel/plugin-transform-runtime": "^7.29.7", - "@babel/preset-env": "^7.29.7", - "@babel/preset-react": "^7.29.7", - "@babel/preset-typescript": "^7.29.7", + "@babel/core": "^8.0.1", + "@babel/plugin-transform-runtime": "^8.0.1", + "@babel/preset-env": "^8.0.2", + "@babel/preset-react": "^8.0.1", + "@babel/preset-typescript": "^8.0.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -74,7 +74,6 @@ "copy-webpack-plugin": "^14.0.0", "cross-env": "^10.1.0", "highlight.js": "^11.11.1", - "html-webpack-plugin": "^5.6.7", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "mikel": "^0.38.1", @@ -86,6 +85,7 @@ "purgecss": "^8.0.0", "react-test-renderer": "^18.2.0", "serve": "^14.2.4", + "typescript": "^6.0.3", "webpack": "^5.107.2", "webpack-cli": "^7.0.3" }, diff --git a/workers/cloud/package.json b/workers/cloud/package.json index 74fe1386..17183854 100644 --- a/workers/cloud/package.json +++ b/workers/cloud/package.json @@ -11,14 +11,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "cookie": "^1.1.1", + "cookie": "^2.0.0", "ms": "^2.1.3", "yaml": "^2.9.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260617.1", "@types/ms": "^2.1.0", - "@types/node": "^25.9.3", + "@types/node": "^26.0.1", "typescript": "^6.0.3", "wrangler": "^4.101.0" }, From 68773c75040e1e5673b4c21f2cd2fdfb777d5e1f Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 28 Jun 2026 23:35:13 +0200 Subject: [PATCH 03/58] Initialize folio-app package --- folio-app/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/folio-app/package.json b/folio-app/package.json index 94f34a7b..ad47971a 100644 --- a/folio-app/package.json +++ b/folio-app/package.json @@ -17,7 +17,8 @@ "lowcss-forms": "^0.33.0", "lowcss-helpers": "^0.33.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router": "^8.0.1" }, "devDependencies": { "html-webpack-plugin": "^5.6.7", From 7cb3610c98315ca9a466813a4eb13ce76bc7b09f Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 28 Jun 2026 23:35:52 +0200 Subject: [PATCH 04/58] Initialize types for folio-app --- folio-app/types/profile.ts | 15 +++++++++++++++ folio-app/types/service.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 folio-app/types/profile.ts create mode 100644 folio-app/types/service.ts diff --git a/folio-app/types/profile.ts b/folio-app/types/profile.ts new file mode 100644 index 00000000..15d39f4d --- /dev/null +++ b/folio-app/types/profile.ts @@ -0,0 +1,15 @@ +import type { UseStore } from "idb-keyval"; + +export type LocalServiceProfile = { + dbName: string; + storeName: string; + initialize: (store: UseStore) => Promise; +}; + +export type Profile = { + name?: string; + config?: object | (() => Promise); + services?: { + local?: null | LocalServiceProfile; + } +}; diff --git a/folio-app/types/service.ts b/folio-app/types/service.ts new file mode 100644 index 00000000..dc0350cd --- /dev/null +++ b/folio-app/types/service.ts @@ -0,0 +1,36 @@ +export type Document = { + name?: string; + id?: string; + value?: object; + metadata?: object; +}; + +export type Credentials = { + token?: string; + username?: string; + password?: string; +}; + +// general interface to access to storage +export interface StorageService { + // documents api + getDocuments(): Promise; + getDocument(id: string): Promise; + createDocument(data: Document): Promise; + updateDocument(id: string, data: Partial): Promise; + deleteDocument(id: string): Promise; + + // preferences api + getPreferences(): Promise; + updatePreferences(preferences: object): Promise; +}; + +// local service type +export interface LocalService extends StorageService {}; + +// cloud service type +export interface CloudService extends StorageService { + login(credentials: Credentials): Promise; + logout(): Promise; + isAuthenticated(): Promise; +}; From 9ca2b13174d7dc71c61b6590ddda650a48d2fecb Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 28 Jun 2026 23:36:28 +0200 Subject: [PATCH 05/58] Iniitalize profile for folio lite version --- folio-app/profiles/lite.ts | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 folio-app/profiles/lite.ts diff --git a/folio-app/profiles/lite.ts b/folio-app/profiles/lite.ts new file mode 100644 index 00000000..69850c5d --- /dev/null +++ b/folio-app/profiles/lite.ts @@ -0,0 +1,39 @@ +import * as idb from "idb-keyval"; +import type { Profile } from "../types/profile.ts"; +import type { UseStore } from "idb-keyval"; + +// internal utility method to rename an IDB key to another +const idbRename = async (prevKey: string, newKey: string, store: UseStore) => { + await idb.set(newKey, (await idb.get(prevKey, store)), store); + await idb.del(prevKey, store); +}; + +export default { + name: "Folio Lite", + config: () => { + return Promise.resolve({}); + }, + services: { + local: { + dbName: "folio", + storeName: "folio-store", + initialize: async (store: UseStore) => { + const keys: string[] = await idb.keys(store); + if (keys.includes("folio:version")) { + // 1. rename all keys starting with the word 'folio:' + for (const prevKey of keys) { + if (prevKey.startsWith("folio:")) { + await idbRename(prevKey, prevKey.replace("folio:", ""), store); + } + } + // 2. check if we have to rename 'data' to 'board:default' + if (keys.includes("folio:data")) { + await idbRename("data", "board:default", store); + } + // 3. remove unused keys + await idb.del("folio:version", store); + } + }, + }, + }, +} as Profile; From 2cf7ff382142812fb0391cd6eaa3d18539b49c4a Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 28 Jun 2026 23:52:54 +0200 Subject: [PATCH 06/58] init .gitignore file --- folio-app/.gitignore | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 folio-app/.gitignore diff --git a/folio-app/.gitignore b/folio-app/.gitignore new file mode 100644 index 00000000..415ca9a4 --- /dev/null +++ b/folio-app/.gitignore @@ -0,0 +1,7 @@ +# nodejs stuff +node_modules +yarn.lock +package-lock.json + +# output +www From 754c032c7f2fff1d9daeb62172ecea434ebc6112 Mon Sep 17 00:00:00 2001 From: Josemi Date: Thu, 2 Jul 2026 21:12:10 +0200 Subject: [PATCH 07/58] Update dependencies in package.json --- folio-app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/folio-app/package.json b/folio-app/package.json index ad47971a..3d2242c1 100644 --- a/folio-app/package.json +++ b/folio-app/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "yarn clean && cross-env NODE_ENV=production webpack build", + "build": "yarn clean && webpack build", "clean": "rm -rf www", "dev": "yarn clean && webpack serve", "typecheck": "tsc --noEmit" From da4f3f654de56b19851f3ddc40ba32e3397e32c3 Mon Sep 17 00:00:00 2001 From: Josemi Date: Thu, 2 Jul 2026 21:12:25 +0200 Subject: [PATCH 08/58] add folio-app development script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2ac9f21c..63d9ba63 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "clean": "rm -rf www", "deploy:ai": "yarn workspace folio-ai deploy", "dev": "serve www -p 8080", - "dev:app": "yarn dev:studio", + "dev:app": "cross-env NODE_ENV=development FOLIO_PROFILE=development yarn workspace folio-app dev", "dev:docs": "serve .cache/documentation -p 8080", "dev:landing": "serve .cache/landing -p 8080", "dev:lite": "cross-env NODE_ENV=development yarn workspace folio-lite dev", From 2655600c023e8d94f61ffb37ddac2622ae18e08f Mon Sep 17 00:00:00 2001 From: Josemi Date: Thu, 2 Jul 2026 21:13:09 +0200 Subject: [PATCH 09/58] Update folio-app types --- folio-app/types/clients.ts | 16 ++++++++++++++++ folio-app/types/profile.ts | 11 ++++++++--- folio-app/types/service.ts | 36 ------------------------------------ 3 files changed, 24 insertions(+), 39 deletions(-) create mode 100644 folio-app/types/clients.ts delete mode 100644 folio-app/types/service.ts diff --git a/folio-app/types/clients.ts b/folio-app/types/clients.ts new file mode 100644 index 00000000..56c28544 --- /dev/null +++ b/folio-app/types/clients.ts @@ -0,0 +1,16 @@ +export type Document = { + name?: string; + id?: string; + value?: object; + metadata?: object; +}; + +// general interface to access to storage +export interface StorageClient { + list(): Promise; + get(id: string): Promise; + create(data: Document): Promise; + update(id: string, data: Partial): Promise; + delete(id: string): Promise; +}; + diff --git a/folio-app/types/profile.ts b/folio-app/types/profile.ts index 15d39f4d..ed1dfe33 100644 --- a/folio-app/types/profile.ts +++ b/folio-app/types/profile.ts @@ -1,15 +1,20 @@ import type { UseStore } from "idb-keyval"; -export type LocalServiceProfile = { +export type LocalStorageConfig = { dbName: string; storeName: string; - initialize: (store: UseStore) => Promise; + initialize?: (store: UseStore) => Promise; +}; + +export type RemoteStorageConfig = { + host: string; }; export type Profile = { name?: string; config?: object | (() => Promise); services?: { - local?: null | LocalServiceProfile; + localStorage?: LocalStorageConfig; + remoteStorage?: RemoteStorageConfig; } }; diff --git a/folio-app/types/service.ts b/folio-app/types/service.ts deleted file mode 100644 index dc0350cd..00000000 --- a/folio-app/types/service.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type Document = { - name?: string; - id?: string; - value?: object; - metadata?: object; -}; - -export type Credentials = { - token?: string; - username?: string; - password?: string; -}; - -// general interface to access to storage -export interface StorageService { - // documents api - getDocuments(): Promise; - getDocument(id: string): Promise; - createDocument(data: Document): Promise; - updateDocument(id: string, data: Partial): Promise; - deleteDocument(id: string): Promise; - - // preferences api - getPreferences(): Promise; - updatePreferences(preferences: object): Promise; -}; - -// local service type -export interface LocalService extends StorageService {}; - -// cloud service type -export interface CloudService extends StorageService { - login(credentials: Credentials): Promise; - logout(): Promise; - isAuthenticated(): Promise; -}; From 2f220d9dfe7e8a5ca6c1e608af5011decad183c5 Mon Sep 17 00:00:00 2001 From: Josemi Date: Thu, 2 Jul 2026 21:14:38 +0200 Subject: [PATCH 10/58] Add clients for folio-app --- folio-app/clients/local.ts | 33 +++++++++++++++++++++++++++++++++ folio-app/clients/remote.ts | 25 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 folio-app/clients/local.ts create mode 100644 folio-app/clients/remote.ts diff --git a/folio-app/clients/local.ts b/folio-app/clients/local.ts new file mode 100644 index 00000000..e6e50c86 --- /dev/null +++ b/folio-app/clients/local.ts @@ -0,0 +1,33 @@ +import * as idb from "idb-keyval"; +import type { UseStore } from "idb-keyval"; +import type { StorageClient, Document } from "../types/clients.ts"; +import type { LocalStorageConfig } from "../types/profile.ts"; + +export const createLocalStorageClient = async (options: LocalStorageConfig): Promise => { + // 1. create the store + const store: UseStore = idb.createStore(options.dbName, options.storeName); + // 2. check if we have to initialize the store + if (store && typeof options?.initialize === "function") { + await options.initialize(store); + } + // 3. return the storage client + return Promise.resolve({ + list: () => Promise.resolve([]), + get: async (id: string) => { + const result = await idb.get(id, store); + return Promise.resolve({ + value: result, + metadata: {}, + }); + }, + create: (data: Document) => { + return idb.set(data.id as string, data.value, store); + }, + update: (id: string, data: Partial) => { + return idb.update(id, (prevValue) => ({ ...prevValue, ...data.value }), store); + }, + delete: (id: string) => { + return idb.del(id, store); + }, + } as StorageClient); +}; diff --git a/folio-app/clients/remote.ts b/folio-app/clients/remote.ts new file mode 100644 index 00000000..2d1cfa93 --- /dev/null +++ b/folio-app/clients/remote.ts @@ -0,0 +1,25 @@ +import { createApiClient } from "../lib/api.ts"; +import type { StorageClient } from "../types/clients.ts"; +import type { RemoteStorageConfig } from "../types/profile.ts"; + +// create a remote client +export const createRemoteStorageClient = (options: RemoteStorageConfig): Promise => { + const api = createApiClient(options.host); + return Promise.resolve({ + list: () => { + return api("GET", "/"); + }, + create: (data) => { + return api("POST", "/", data); + }, + get: (id) => { + return api("GET", `/${id}`); + }, + update: (id, data) => { + return api("PATCH", `/${id}`, data); + }, + delete: (id) => { + return api("DELETE", `/${id}`); + }, + } as StorageClient); +}; From aa4454096c6183cfae0d4510b0c7d656896de5c9 Mon Sep 17 00:00:00 2001 From: Josemi Date: Thu, 2 Jul 2026 21:14:55 +0200 Subject: [PATCH 11/58] Initialize api fetch wrapper --- folio-app/lib/api.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 folio-app/lib/api.ts diff --git a/folio-app/lib/api.ts b/folio-app/lib/api.ts new file mode 100644 index 00000000..8095a41b --- /dev/null +++ b/folio-app/lib/api.ts @@ -0,0 +1,53 @@ +export type ApiClient = (method: string, path: string, data?: any) => Promise; + +// internal method to reject the promise with a standardized error format +const rejectResponse = (response: any): Promise => { + if (response && Array.isArray(response.errors) && response.errors[0]?.message) { + return Promise.reject(new Error(response.errors[0].message)); + } + // generic error if the response is not in the expected format + return Promise.reject(new Error("An error occurred while processing the request.")); +}; + +export const createApiClient = (host: string = ""): ApiClient => { + return (method: string, path: string, data?: any): Promise => { + const requestUrl = new URL(path, host).href; + // construct the URL based on the base URL and path + const options: RequestInit = { + method: method || "GET", + headers: { + "Content-Type": "application/json", + }, + }; + // include the Authorization header if token is set + // if (token) { + // (options.headers as Record)["Authorization"] = `Bearer ${token}`; + // } + // include the body if method is POST or PATCH and data is provided + if (data && (method === "POST" || method === "PATCH" || method === "PUT")) { + options.body = JSON.stringify(data); + } + // perform the request + return fetch(requestUrl, options) + .then(response => { + // if the response is not ok, we throw an error + if (!response.ok) { + // try to parse the response as JSON to get the errors + return response.json().then(responseData => { + return rejectResponse(responseData); + }); + } + // if the response is ok, we return the response as JSON + return response.json(); + }) + .then(responseData => { + // if there is a field named "errors" in the response, we reject the promise + // this is a common pattern in GraphQL responses, but can also be used in REST + if (responseData.errors) { + return rejectResponse(responseData); + } + // return the response data object to process + return responseData?.data || null; + }); + }; +}; From 74e38636cce9cb8e8421c5040ae5dc58a1d54c87 Mon Sep 17 00:00:00 2001 From: Josemi Date: Thu, 2 Jul 2026 21:15:27 +0200 Subject: [PATCH 12/58] Initialize webpack config --- folio-app/template.html | 33 +++++++++++ folio-app/webpack.config.js | 115 ++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 folio-app/template.html create mode 100644 folio-app/webpack.config.js diff --git a/folio-app/template.html b/folio-app/template.html new file mode 100644 index 00000000..41be93cf --- /dev/null +++ b/folio-app/template.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + + Folio + + + + +
+
+
Folio is not optimized for this device :(
+
+ Please switch to a desktop or tablet browser for using Folio. + Mobile devices may exhibit unexpected behaviours. +
+
+
+ + diff --git a/folio-app/webpack.config.js b/folio-app/webpack.config.js new file mode 100644 index 00000000..02403bf6 --- /dev/null +++ b/folio-app/webpack.config.js @@ -0,0 +1,115 @@ +import { resolve, join } from "node:path"; +import webpack from "webpack"; +import CopyWebpackPlugin from "copy-webpack-plugin"; +import HtmlWebpackPlugin from "html-webpack-plugin"; +import { createServer } from "../scripts/webpack-server.js"; + +import pkg from "../package.json" with { type: "json" }; +// import rules from "./__stubs/rules.json" with { type: "json" }; + +export default { + mode: process.env.NODE_ENV || "development", + target: "web", + entry: resolve("index.tsx"), + output: { + path: resolve("www"), + publicPath: "./", + filename: "[name].[contenthash].js", + chunkFilename: "[name].[contenthash].chunk.js", + }, + resolve: { + alias: { + "folio-react": resolve("../folio-react/"), + "@profile": resolve("./profiles/development.ts"), + }, + extensions: [".tsx", ".ts", ".js", ".jsx"], + }, + optimization: { + splitChunks: { + chunks: "all", + }, + }, + devServer: { + hot: false, + static: { + directory: resolve("www"), + staticOptions: { + extensions: ["html"], + }, + }, + historyApiFallback: { + rewrites: [ + { from: /^\/$/, to: "index.html" }, + // {from: /^\/index.html$/, to: "app.html"}, + ], + }, + // setupMiddlewares: createServer(rules), + devMiddleware: { + writeToDisk: true, + }, + }, + module: { + rules: [ + { + test: /\.(j|t)sx?$/, + include: [ + resolve("."), + resolve("../folio-react"), + ], + exclude: /(node_modules|www)/, + loader: "babel-loader", + options: { + presets: [ + "@babel/preset-env", + [ + "@babel/preset-react", + { + runtime: "automatic", + }, + ], + "@babel/preset-typescript", + ], + plugins: [ + "@babel/plugin-transform-runtime", + ], + }, + }, + { + test: /\.(png|jpg|jpeg|svg)$/, + type: "asset/inline", + }, + ], + }, + plugins: [ + new webpack.ProgressPlugin(), + new webpack.DefinePlugin({ + "process.env.VERSION": JSON.stringify(pkg.version), + "process.env.URL_REPOSITORY": JSON.stringify(pkg.repository), + "process.env.URL_ISSUES": JSON.stringify(pkg.bugs), + "process.env.URL_HOMEPAGE": JSON.stringify(pkg.homepage), + "process.env.AI_HOST": JSON.stringify(process.env.FOLIO_LITE_AI_HOST || ""), + }), + new CopyWebpackPlugin({ + patterns: [ + resolve("../node_modules/lowcss/low.css"), + { + from: resolve("../node_modules/lowcss-helpers/index.css"), + to: "low.helpers.css", + }, + { + from: resolve("../node_modules/lowcss-forms/index.css"), + to: "low.forms.css", + }, + { + from: resolve("../resources/favicon-32x32.png"), + to: "favicon.png", + }, + ], + }), + new HtmlWebpackPlugin({ + template: resolve("template.html"), + filename: "index.html", + minify: true, + }), + ], +}; From 29e463c7d17176f6d783d0203e7083940c6fd37d Mon Sep 17 00:00:00 2001 From: Josemi Date: Thu, 2 Jul 2026 21:16:30 +0200 Subject: [PATCH 13/58] Initialize folio-app components --- folio-app/components/app.tsx | 27 +++++++ folio-app/components/editor.tsx | 122 ++++++++++++++++++++++++++++++ folio-app/components/welcome.tsx | 94 +++++++++++++++++++++++ folio-app/contexts/clients.tsx | 62 +++++++++++++++ folio-app/index.tsx | 11 +++ folio-app/profiles/development.ts | 19 +++++ folio-app/tsconfig.json | 18 +++++ 7 files changed, 353 insertions(+) create mode 100644 folio-app/components/app.tsx create mode 100644 folio-app/components/editor.tsx create mode 100644 folio-app/components/welcome.tsx create mode 100644 folio-app/contexts/clients.tsx create mode 100644 folio-app/index.tsx create mode 100644 folio-app/profiles/development.ts create mode 100644 folio-app/tsconfig.json diff --git a/folio-app/components/app.tsx b/folio-app/components/app.tsx new file mode 100644 index 00000000..1a2ba726 --- /dev/null +++ b/folio-app/components/app.tsx @@ -0,0 +1,27 @@ +import { useMemo } from "react"; +import { createBrowserRouter, RouterProvider } from "react-router"; +import { useClients } from "../contexts/clients.tsx"; +import { Welcome } from "./welcome.tsx"; +import type { JSX } from "react"; + +export const App = (): JSX.Element => { + const clients = useClients(); + const router = useMemo(() => { + const routes = []; + if (clients.localStorage) { + routes.push({ + path: "/", + Component: Welcome, + }); + // routes.push({ + // path: "/board", + // }); + } + // create a browser router using routes generated from available clients + return createBrowserRouter(routes); + }, [clients]); + + return ( + + ); +}; diff --git a/folio-app/components/editor.tsx b/folio-app/components/editor.tsx new file mode 100644 index 00000000..f1e7619a --- /dev/null +++ b/folio-app/components/editor.tsx @@ -0,0 +1,122 @@ +import { useState, useRef, useMemo, useCallback, useEffect } from "react"; +import { PREFERENCES } from "folio-react/constants.js"; +import { Editor as FolioEditor } from "folio-react/components/editor.tsx"; +import { Title } from "folio-react/components/title.tsx"; +// import { Library } from "folio-react/components/library.tsx"; +import { Preferences as PreferencesDialog, PreferencesContent } from "folio-react/components/preferences.tsx"; +import { Loading } from "folio-react/components/loading.jsx"; +import type { JSX } from "react"; +import type { Preferences } from "folio-react/contexts/preferences.tsx"; +import type { StorageService } from "../types/service.ts"; + +export type EditorProps = { + id: string; + storage: StorageService; +}; + +// internal method to change the document title +// const setDocumentTitle = (title: string) => { +// document.title = `${title} - folio lite`; +// }; + +export const Editor = ({ id, storage }: EditorProps): JSX.Element => { + const [ready, setReady] = useState(false); + const [preferences, setPreferences] = useState | null>(null); + // const currentTitle = useRef("Untitled"); + + // we are using a reference to the initial preferences to prevent saving the same loaded preferences + // when the component is initialized + const initialPreferences = useRef(null); + + const componentsOverrides = useMemo(() => { + return { + Title: Title, + // Library: Library, + Preferences: () => ( + + { + setPreferences((prevPreferences: any) => { + return Object.assign({}, prevPreferences, newPreferences); + }); + }} + /> + + ), + }; + }, [setPreferences]); + + // default preferences for folio-lite app + const mergedPreferences = useMemo(() => { + return { + [PREFERENCES.AI_ENABLED]: false, + ...preferences, + }; + }, [preferences]); + + // this is a wrapper around store.getInitialData to get and update the document title + const handleDataLoad = useCallback(() => { + storage.getDocument(id).then(data => { + // if (data?.value?.title) { + // setDocumentTitle(data.title); + // currentTitle.current = data.title; + // } + return data?.value; + }); + }, [id, storage]); + + // when the data in the editor changes, run store.updateData + const handleDataChange = useCallback((data: any) => { + storage.updateDocument(id, { + value: data, + }); + // update the document title if it has changed + // if (data?.title && data.title !== currentTitle.current) { + // setDocumentTitle(data.title); + // currentTitle.current = data.title; + // } + }, [storage]); + + // when the library in the editor changes, run store.updateLibrary + // const handleLibraryChange = useCallback((library: any) => { + // props.store.updateLibrary(library); + // }, [props.store]); + + // when the component is mounted, fetch preferences + useEffect(() => { + storage.getPreferences() + .then(savedPreferences => { + initialPreferences.current = savedPreferences || {}; + }) + .catch(error => { + console.error(error); + initialPreferences.current = {}; + }) + .finally(() => { + setPreferences(initialPreferences.current); + setReady(true); + }); + }, [id, storage]); + + // when preferences change, call useEffect to update the preferences storage + useEffect(() => { + if (preferences && preferences !== initialPreferences.current) { + storage.updatePreferences(preferences); + } + }, [storage, preferences]); + + if (!ready || !preferences) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/folio-app/components/welcome.tsx b/folio-app/components/welcome.tsx new file mode 100644 index 00000000..3632c99c --- /dev/null +++ b/folio-app/components/welcome.tsx @@ -0,0 +1,94 @@ +import { Fragment, useCallback } from "react"; +import { useNavigate } from "react-router"; +import { + renderIcon, + DrawingIcon, + FolderIcon, +} from "@josemi-icons/react"; +import { Button } from "folio-react/components/ui/button.tsx"; +import { Centered } from "folio-react/components/ui/centered.tsx"; +import { Overlay } from "folio-react/components/ui/overlay.tsx"; +// import { loadFromJson } from "folio-react/lib/json.js"; +import type { JSX } from "react"; + +const defaultWelcomeFeatures = [ + { + icon: "shield-check", + title: "Privacy First.", + description: "Folio uses the local storage of your browser to ensure that your drawings are accessible only to you.", + }, + { + icon: "drawing", + title: "Boundless Canvas.", + description: "Unleash your imagination on an infinite canvas for sketching, prototyping, and creating.", + }, +]; + +export const Welcome = (): JSX.Element => { + const navigate = useNavigate(); + const handleLoad = useCallback(() => { + // loadFromJson() + // .then(data => { + // editor.fromJSON(data); + // editor.dispatchChange(); + // editor.update(); + // setWelcomeVisible(false); + // }) + // .catch(error => console.error(error)); + navigate("/board"); + }, [navigate]); + const handleStartDrawing = useCallback(() => { + navigate("/board"); + }, [navigate]); + + return ( + + + +
+
+
+ folio + lite +
+
+ Welcome to folio, the minimal and infinite whiteboard for sketching and prototyping. +
+
+
+ {defaultWelcomeFeatures.map(feature => ( +
+
+ {renderIcon(feature.icon)} +
+
+ {feature.title} +
+
+ {feature.description} +
+
+ ))} +
+
+ + +
+
+ folio v{process.env.VERSION} +
+
+
+
+ ); +}; diff --git a/folio-app/contexts/clients.tsx b/folio-app/contexts/clients.tsx new file mode 100644 index 00000000..02d76292 --- /dev/null +++ b/folio-app/contexts/clients.tsx @@ -0,0 +1,62 @@ +import { useState, useEffect, createContext, useContext } from "react"; +import profile from "@profile"; +import { createLocalStorageClient } from "../clients/local.ts"; +import { createRemoteStorageClient } from "../clients/remote.ts"; +import type { PropsWithChildren, JSX } from "react"; +import type { StorageClient } from "../types/clients"; + +export type Clients = { + localStorage?: StorageClient | null; + remoteStorage?: StorageClient | null; + authentication?: null; +}; + +// create clients from profile +const createClientsFromProfile = async (): Promise => { + const clients: Clients = { + localStorage: null, + remoteStorage: null, + }; + // initialize local storage client + if (profile?.services?.localStorage) { + clients.localStorage = await createLocalStorageClient(profile.services.localStorage); + } + // initialize remote storage client + if (profile?.services?.remoteStorage) { + clients.remoteStorage = await createRemoteStorageClient(profile.services.remoteStorage); + } + return Promise.resolve(clients); +}; + +// cloud service context +export const ClientsContext = createContext({}); + +// hook to access to clients +export const useClients = (): Clients => { + return useContext(ClientsContext); +}; + +// main clients provider +export const ClientsProvider = (props: PropsWithChildren): JSX.Element => { + const [clients, setClients] = useState(null); + + // initialize clients + useEffect(() => { + createClientsFromProfile().then(initialClients => { + setClients(initialClients); + }); + }, []); + + // if service is defined and is not initialized, display a loading screen + if (!clients) { + return ( +
Loading...
+ ); + } + + return ( + + {props.children} + + ); +}; diff --git a/folio-app/index.tsx b/folio-app/index.tsx new file mode 100644 index 00000000..f5bee10f --- /dev/null +++ b/folio-app/index.tsx @@ -0,0 +1,11 @@ +import { createRoot } from "react-dom/client"; +import { App } from "./components/app.tsx"; +import { ClientsProvider } from "./contexts/clients.tsx"; + +createRoot(document.getElementById("root") as HTMLDivElement).render(( +
+ + + +
+)); diff --git a/folio-app/profiles/development.ts b/folio-app/profiles/development.ts new file mode 100644 index 00000000..d9fad89d --- /dev/null +++ b/folio-app/profiles/development.ts @@ -0,0 +1,19 @@ +import * as idb from "idb-keyval"; +import type { Profile } from "../types/profile.ts"; +import type { UseStore } from "idb-keyval"; + +export default { + name: "Folio Development", + config: () => { + return Promise.resolve({}); + }, + services: { + localStorage: { + dbName: "folio", + storeName: "folio-store", + initialize: async (store: UseStore) => { + // TODO: initialize storage + }, + }, + }, +} as Profile; diff --git a/folio-app/tsconfig.json b/folio-app/tsconfig.json new file mode 100644 index 00000000..e2903f4c --- /dev/null +++ b/folio-app/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "noEmit": true, + "paths": { + "@profile": [ + "./profiles/development.ts" + ], + "folio-react": [ + "../folio-react/index.jsx", + ], + } + } +} \ No newline at end of file From d8a6a19ae63692dcb7cdadc512063c56f6c65fd9 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 11 Jul 2026 19:52:11 +0200 Subject: [PATCH 14/58] Update dependencies --- folio-app/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/folio-app/package.json b/folio-app/package.json index 3d2242c1..ec56deb0 100644 --- a/folio-app/package.json +++ b/folio-app/package.json @@ -16,8 +16,8 @@ "lowcss": "^0.33.0", "lowcss-forms": "^0.33.0", "lowcss-helpers": "^0.33.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-router": "^8.0.1" }, "devDependencies": { From 4fd51a8af6ccb5eb77aeee547cdddeb13ee89cf3 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 11 Jul 2026 19:52:31 +0200 Subject: [PATCH 15/58] Fix typescript configuration --- folio-app/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/folio-app/tsconfig.json b/folio-app/tsconfig.json index e2903f4c..06f0cf89 100644 --- a/folio-app/tsconfig.json +++ b/folio-app/tsconfig.json @@ -12,6 +12,7 @@ ], "folio-react": [ "../folio-react/index.jsx", + "../folio-react/*" ], } } From e94e5af736c4f5398d6b2095a6e446372cae2a02 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 11 Jul 2026 19:53:34 +0200 Subject: [PATCH 16/58] Add route for editor --- folio-app/components/app.tsx | 17 ++++++++++-- folio-app/components/editor.tsx | 49 +++++++++++++++++++++------------ 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/folio-app/components/app.tsx b/folio-app/components/app.tsx index 1a2ba726..e8dc9409 100644 --- a/folio-app/components/app.tsx +++ b/folio-app/components/app.tsx @@ -2,7 +2,10 @@ import { useMemo } from "react"; import { createBrowserRouter, RouterProvider } from "react-router"; import { useClients } from "../contexts/clients.tsx"; import { Welcome } from "./welcome.tsx"; +import { Editor } from "./editor.tsx"; +import { LocalStorageKeys } from "../constants.ts"; import type { JSX } from "react"; +import type { StorageClient } from "../types/clients.ts"; export const App = (): JSX.Element => { const clients = useClients(); @@ -13,9 +16,17 @@ export const App = (): JSX.Element => { path: "/", Component: Welcome, }); - // routes.push({ - // path: "/board", - // }); + routes.push({ + path: "/board", + Component: () => ( + + ), + }); } // create a browser router using routes generated from available clients return createBrowserRouter(routes); diff --git a/folio-app/components/editor.tsx b/folio-app/components/editor.tsx index f1e7619a..6d25a7f7 100644 --- a/folio-app/components/editor.tsx +++ b/folio-app/components/editor.tsx @@ -7,11 +7,12 @@ import { Preferences as PreferencesDialog, PreferencesContent } from "folio-reac import { Loading } from "folio-react/components/loading.jsx"; import type { JSX } from "react"; import type { Preferences } from "folio-react/contexts/preferences.tsx"; -import type { StorageService } from "../types/service.ts"; +import type { StorageClient } from "../types/clients.ts"; export type EditorProps = { - id: string; - storage: StorageService; + dataId: string; + preferencesId: string; + storage: StorageClient; }; // internal method to change the document title @@ -19,14 +20,15 @@ export type EditorProps = { // document.title = `${title} - folio lite`; // }; -export const Editor = ({ id, storage }: EditorProps): JSX.Element => { +export const Editor = ({ dataId, preferencesId, storage }: EditorProps): JSX.Element => { const [ready, setReady] = useState(false); const [preferences, setPreferences] = useState | null>(null); // const currentTitle = useRef("Untitled"); // we are using a reference to the initial preferences to prevent saving the same loaded preferences // when the component is initialized - const initialPreferences = useRef(null); + const initialPreferences = useRef(null); + const initialData = useRef(null); const componentsOverrides = useMemo(() => { return { @@ -49,33 +51,39 @@ export const Editor = ({ id, storage }: EditorProps): JSX.Element => { // default preferences for folio-lite app const mergedPreferences = useMemo(() => { return { - [PREFERENCES.AI_ENABLED]: false, + // [PREFERENCES.AI_ENABLED]: false, ...preferences, }; }, [preferences]); // this is a wrapper around store.getInitialData to get and update the document title const handleDataLoad = useCallback(() => { - storage.getDocument(id).then(data => { + return storage.get(dataId).then(data => { // if (data?.value?.title) { // setDocumentTitle(data.title); // currentTitle.current = data.title; // } - return data?.value; + initialData.current = data; + return data?.value || {}; }); - }, [id, storage]); + }, [dataId, storage]); // when the data in the editor changes, run store.updateData const handleDataChange = useCallback((data: any) => { - storage.updateDocument(id, { + // TODO: we have to include the metadata in the data submitted to the storage + storage.update(dataId, { value: data, + metadata: { + ...(initialData?.current?.metadata || {}), + updated_at: Date.now(), + }, }); // update the document title if it has changed // if (data?.title && data.title !== currentTitle.current) { // setDocumentTitle(data.title); // currentTitle.current = data.title; // } - }, [storage]); + }, [dataId, storage]); // when the library in the editor changes, run store.updateLibrary // const handleLibraryChange = useCallback((library: any) => { @@ -84,24 +92,29 @@ export const Editor = ({ id, storage }: EditorProps): JSX.Element => { // when the component is mounted, fetch preferences useEffect(() => { - storage.getPreferences() - .then(savedPreferences => { - initialPreferences.current = savedPreferences || {}; + storage.get(preferencesId) + .then(data => { + initialPreferences.current = data || {}; }) .catch(error => { console.error(error); initialPreferences.current = {}; }) .finally(() => { - setPreferences(initialPreferences.current); + setPreferences(initialPreferences.current?.value || {}); setReady(true); }); - }, [id, storage]); + }, [preferencesId, storage]); // when preferences change, call useEffect to update the preferences storage useEffect(() => { - if (preferences && preferences !== initialPreferences.current) { - storage.updatePreferences(preferences); + if (preferences && preferences !== initialPreferences.current?.value) { + storage.update(preferencesId, { + value: preferences, + metadata: { + updated_at: Date.now(), + }, + }); } }, [storage, preferences]); From a6e11d87d0c64be114eda4b762e6de5717a2841b Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 11 Jul 2026 19:53:53 +0200 Subject: [PATCH 17/58] Fix local client in context --- folio-app/contexts/clients.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/folio-app/contexts/clients.tsx b/folio-app/contexts/clients.tsx index 02d76292..1fcd0e8d 100644 --- a/folio-app/contexts/clients.tsx +++ b/folio-app/contexts/clients.tsx @@ -39,8 +39,6 @@ export const useClients = (): Clients => { // main clients provider export const ClientsProvider = (props: PropsWithChildren): JSX.Element => { const [clients, setClients] = useState(null); - - // initialize clients useEffect(() => { createClientsFromProfile().then(initialClients => { setClients(initialClients); @@ -55,8 +53,8 @@ export const ClientsProvider = (props: PropsWithChildren): JSX.Element => { } return ( - + {props.children} - + ); }; From 149f5abf8a927693f3fdd63191df75485a75b556 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 11 Jul 2026 19:54:20 +0200 Subject: [PATCH 18/58] Fix typo in tests --- folio-react/contexts/__tests__/surface.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/folio-react/contexts/__tests__/surface.test.tsx b/folio-react/contexts/__tests__/surface.test.tsx index 079efe63..164c43c6 100644 --- a/folio-react/contexts/__tests__/surface.test.tsx +++ b/folio-react/contexts/__tests__/surface.test.tsx @@ -28,7 +28,7 @@ const HookCaller = ({ onMount }: { onMount: (manager: any) => void }) => { describe("Surface", () => { describe("when using outside SurfaceProvider", () => { - let consoleSpy: jest.SpyInstance; + let consoleSpy: any; beforeAll(() => { consoleSpy = jest.spyOn(console, "error").mockImplementation(() => { }); From 1f8065f3a76db39df1a6b65ace81113366d8e75d Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 11 Jul 2026 19:54:45 +0200 Subject: [PATCH 19/58] Initialize constants for folio-app --- folio-app/constants.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 folio-app/constants.ts diff --git a/folio-app/constants.ts b/folio-app/constants.ts new file mode 100644 index 00000000..a37c8f65 --- /dev/null +++ b/folio-app/constants.ts @@ -0,0 +1,8 @@ +export enum LocalStorageKeys { + BOARD = "data:board", + PREFERENCES = "preferences", +}; + +export enum RemoteStorageKeys { + PREFERENCES = "preferences", +}; From 143ca1155104c4f091d901726a78c855e6211427 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 12 Jul 2026 20:20:50 +0200 Subject: [PATCH 20/58] Working on folio-app --- folio-app/__stubs/authentication.js | 58 ++++++++++++ folio-app/__stubs/rules.json | 59 ++++++++++++ folio-app/__stubs/storage.json | 0 folio-app/clients/authentication.ts | 34 +++++++ folio-app/clients/remote.ts | 25 ----- folio-app/clients/{local.ts => storage.ts} | 24 ++++- folio-app/components/app.tsx | 21 ++++- folio-app/components/dashboard.tsx | 0 folio-app/components/login.tsx | 94 +++++++++++++++++++ folio-app/constants.ts | 3 + folio-app/contexts/clients.tsx | 13 ++- folio-app/contexts/configuration.tsx | 55 +++++++++++ folio-app/contexts/toaster.tsx | 101 +++++++++++++++++++++ folio-app/index.tsx | 12 ++- folio-app/lib/api.ts | 12 ++- folio-app/profiles/development.ts | 15 +-- folio-app/types/clients.ts | 10 +- folio-app/types/profile.ts | 5 + 18 files changed, 495 insertions(+), 46 deletions(-) create mode 100644 folio-app/__stubs/authentication.js create mode 100644 folio-app/__stubs/rules.json create mode 100644 folio-app/__stubs/storage.json create mode 100644 folio-app/clients/authentication.ts delete mode 100644 folio-app/clients/remote.ts rename folio-app/clients/{local.ts => storage.ts} (62%) create mode 100644 folio-app/components/dashboard.tsx create mode 100644 folio-app/components/login.tsx create mode 100644 folio-app/contexts/configuration.tsx create mode 100644 folio-app/contexts/toaster.tsx diff --git a/folio-app/__stubs/authentication.js b/folio-app/__stubs/authentication.js new file mode 100644 index 00000000..1e9fa9a6 --- /dev/null +++ b/folio-app/__stubs/authentication.js @@ -0,0 +1,58 @@ +export default [ + { + method: "GET", + path: "/_auth/status", + headers: { + "Content-Type": "application/json" + }, + response: { + body: { + data: { + message: "ok", + } + } + } + }, + { + method: "POST", + path: "/_auth/login", + headers: { + "Content-Type": "application/json", + }, + response: request => { + if (request?.body?.token === "12345") { + return Promise.resolve({ + status: 200, + body: { + data: { + token: "folio_abcde12345", + }, + }, + }); + } + // return a fake error if the token is not 12345 + return Promise.resolve({ + status: 401, + body: { + errors: [ + { message: "Invalid token" }, + ], + }, + }); + }, + }, + { + method: "GET", + path: "/_auth/me", + headers: { + "Content-Type": "application/json", + }, + response: { + body: { + data: { + username: "folio", + }, + }, + }, + }, +]; diff --git a/folio-app/__stubs/rules.json b/folio-app/__stubs/rules.json new file mode 100644 index 00000000..80ca5d8a --- /dev/null +++ b/folio-app/__stubs/rules.json @@ -0,0 +1,59 @@ +[ + { + "method": "GET", + "path": "/_api/status", + "headers": { + "Content-Type": "application/json" + }, + "response": { + "body": { + "data": { + "message": "ok" + } + } + } + }, + { + "method": "GET", + "path": "/_ai/quotas", + "headers": { + "Content-Type": "application/json" + }, + "response": { + "body": { + "data": { + "requestsLimit": 10, + "requestsUsed": 5 + } + } + } + }, + { + "method": "POST", + "path": "/_ai/generateElements", + "headers": { + "Content-Type": "application/json", + "X-Folio-Requests-Limit": 10, + "X-Folio-Requests-Used": 6 + }, + "response": { + "body": { + "data": { + "title": "Fake response", + "message": "Here is a clean version of your diagram.", + "elements": [ + { + "type": "shape", + "shape": "rectangle", + "x1": 0, + "y1": 0, + "x2": 100, + "y2": 100, + "fillColor": "#000" + } + ] + } + } + } + } +] diff --git a/folio-app/__stubs/storage.json b/folio-app/__stubs/storage.json new file mode 100644 index 00000000..e69de29b diff --git a/folio-app/clients/authentication.ts b/folio-app/clients/authentication.ts new file mode 100644 index 00000000..32c94086 --- /dev/null +++ b/folio-app/clients/authentication.ts @@ -0,0 +1,34 @@ +import { createApiClient } from "../lib/api.ts"; +import { TOKEN_KEY } from "../constants.ts"; +import type { AuthenticationClient, Credentials } from "../types/clients.ts"; +import type { AuthenticationConfig } from "../types/profile.ts"; + +export const createAuthenticationClient = (config: AuthenticationConfig) => { + const api = createApiClient(config.host); + return Promise.resolve({ + login: async (credentials: Credentials) => { + const response = await api("POST", "/login", credentials); + if (response?.token) { + window.localStorage.setItem(TOKEN_KEY, response.token); + return Promise.resolve(); + } + // no token returned in response, send as error + return Promise.reject(new Error("Invalid login credentials")); + }, + logout: () => { + window.localStorage.removeItem(TOKEN_KEY); + return Promise.resolve(); + }, + isAuthenticated: () => { + // 1. check if token is saved in local storage and it has the correg structure + const token = window.localStorage.getItem(TOKEN_KEY); + if (!token || typeof token !== "string") { + return Promise.resolve(false); + } + // 2. validate that this token can be used to perform requests to the auth api + // return api("GET", "/me"); + // TODO: implement an advanced verification of the token + return Promise.resolve(true); + }, + } as AuthenticationClient); +}; diff --git a/folio-app/clients/remote.ts b/folio-app/clients/remote.ts deleted file mode 100644 index 2d1cfa93..00000000 --- a/folio-app/clients/remote.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createApiClient } from "../lib/api.ts"; -import type { StorageClient } from "../types/clients.ts"; -import type { RemoteStorageConfig } from "../types/profile.ts"; - -// create a remote client -export const createRemoteStorageClient = (options: RemoteStorageConfig): Promise => { - const api = createApiClient(options.host); - return Promise.resolve({ - list: () => { - return api("GET", "/"); - }, - create: (data) => { - return api("POST", "/", data); - }, - get: (id) => { - return api("GET", `/${id}`); - }, - update: (id, data) => { - return api("PATCH", `/${id}`, data); - }, - delete: (id) => { - return api("DELETE", `/${id}`); - }, - } as StorageClient); -}; diff --git a/folio-app/clients/local.ts b/folio-app/clients/storage.ts similarity index 62% rename from folio-app/clients/local.ts rename to folio-app/clients/storage.ts index e6e50c86..ebffcd7e 100644 --- a/folio-app/clients/local.ts +++ b/folio-app/clients/storage.ts @@ -1,7 +1,8 @@ import * as idb from "idb-keyval"; import type { UseStore } from "idb-keyval"; +import { createApiClient } from "../lib/api.ts"; import type { StorageClient, Document } from "../types/clients.ts"; -import type { LocalStorageConfig } from "../types/profile.ts"; +import type { LocalStorageConfig, RemoteStorageConfig } from "../types/profile.ts"; export const createLocalStorageClient = async (options: LocalStorageConfig): Promise => { // 1. create the store @@ -31,3 +32,24 @@ export const createLocalStorageClient = async (options: LocalStorageConfig): Pro }, } as StorageClient); }; + +export const createRemoteStorageClient = (options: RemoteStorageConfig): Promise => { + const api = createApiClient(options.host); + return Promise.resolve({ + list: () => { + return api("GET", "/"); + }, + create: (data) => { + return api("POST", "/", data); + }, + get: (id) => { + return api("GET", `/${id}`); + }, + update: (id, data) => { + return api("PATCH", `/${id}`, data); + }, + delete: (id) => { + return api("DELETE", `/${id}`); + }, + } as StorageClient); +}; diff --git a/folio-app/components/app.tsx b/folio-app/components/app.tsx index e8dc9409..b9b37f2b 100644 --- a/folio-app/components/app.tsx +++ b/folio-app/components/app.tsx @@ -1,8 +1,9 @@ import { useMemo } from "react"; -import { createBrowserRouter, RouterProvider } from "react-router"; +import { createBrowserRouter, Outlet, RouterProvider, redirect } from "react-router"; import { useClients } from "../contexts/clients.tsx"; import { Welcome } from "./welcome.tsx"; import { Editor } from "./editor.tsx"; +import { Login } from "./login.tsx"; import { LocalStorageKeys } from "../constants.ts"; import type { JSX } from "react"; import type { StorageClient } from "../types/clients.ts"; @@ -28,6 +29,24 @@ export const App = (): JSX.Element => { ), }); } + // check if we have enabled the remote storage and the authentication + if (clients.authentication) { + routes.push({ + path: "/login", + Component: Login, + }); + // protected routes + routes.push({ + loader: async () => { + const isAuthenticated = await clients.authentication?.isAuthenticated(); + if (!isAuthenticated) { + redirect("/login"); + } + return null; + }, + Component: () => , + }); + } // create a browser router using routes generated from available clients return createBrowserRouter(routes); }, [clients]); diff --git a/folio-app/components/dashboard.tsx b/folio-app/components/dashboard.tsx new file mode 100644 index 00000000..e69de29b diff --git a/folio-app/components/login.tsx b/folio-app/components/login.tsx new file mode 100644 index 00000000..4b456d40 --- /dev/null +++ b/folio-app/components/login.tsx @@ -0,0 +1,94 @@ +import { useState, useCallback, useRef } from "react"; +import { useNavigate } from "react-router"; +import { ExclamationCircleIcon } from "@josemi-icons/react"; +import { Button } from "folio-react/components/ui/button.tsx"; +import { Centered } from "folio-react/components/ui/centered.tsx"; +import { useToaster } from "../contexts/toaster.tsx"; +import { useClients } from "../contexts/clients.tsx"; +import { useConfiguration } from "../contexts/configuration.tsx"; +import type { JSX } from "react"; + +const LoginMessageAlert = ({ title, content }: { title: string, content: string }): JSX.Element => { + return ( +
+
+
+ +
+
{title}
+
+
{content}
+
+ ); +}; + +// @description login component +export const Login = (): JSX.Element => { + const navigate = useNavigate(); + const toaster = useToaster(); + const configuration = useConfiguration(); + const { authentication } = useClients(); + const [loading, setLoading] = useState(false); + const accessTokenRef = useRef(null); + + const handleLogin = useCallback(() => { + const accessToken = (accessTokenRef.current?.value || "").trim(); + if (!accessToken) { + return toaster.error("The access token is required to log in."); + } + + // try to login with the provided access token + setLoading(true); + authentication?.login({ token: accessToken }) + .then(() => { + navigate("/"); + }) + .catch(error => { + toaster.error(error?.message || "An error occurred while logging in."); + setLoading(false); + }); + }, [authentication]); + + return ( + +
+
+ {configuration?.login_title || configuration?.title || "folio."} +
+
+ You need to log in with your access token to continue. +
+
+ { + if (event.key === "Enter") { + event.preventDefault(); + handleLogin(); + } + }} + /> +
+ Your access token is printed in the terminal where you run the server. If you don't have it, please contact your administrator. +
+
+ {(configuration?.login_messages || []).map((message: any, index: number) => ( + + ))} +
+ +
+
+
+ ); +}; diff --git a/folio-app/constants.ts b/folio-app/constants.ts index a37c8f65..11fc6420 100644 --- a/folio-app/constants.ts +++ b/folio-app/constants.ts @@ -6,3 +6,6 @@ export enum LocalStorageKeys { export enum RemoteStorageKeys { PREFERENCES = "preferences", }; + +// key in the local storage to save the authentication token +export const TOKEN_KEY = "folio:token"; diff --git a/folio-app/contexts/clients.tsx b/folio-app/contexts/clients.tsx index 1fcd0e8d..30a0301e 100644 --- a/folio-app/contexts/clients.tsx +++ b/folio-app/contexts/clients.tsx @@ -1,14 +1,14 @@ import { useState, useEffect, createContext, useContext } from "react"; import profile from "@profile"; -import { createLocalStorageClient } from "../clients/local.ts"; -import { createRemoteStorageClient } from "../clients/remote.ts"; +import { createRemoteStorageClient, createLocalStorageClient } from "../clients/storage.ts"; +import { createAuthenticationClient } from "../clients/authentication.ts"; import type { PropsWithChildren, JSX } from "react"; -import type { StorageClient } from "../types/clients"; +import type { StorageClient, AuthenticationClient } from "../types/clients"; export type Clients = { localStorage?: StorageClient | null; remoteStorage?: StorageClient | null; - authentication?: null; + authentication?: AuthenticationClient | null; }; // create clients from profile @@ -16,6 +16,7 @@ const createClientsFromProfile = async (): Promise => { const clients: Clients = { localStorage: null, remoteStorage: null, + authentication: null, }; // initialize local storage client if (profile?.services?.localStorage) { @@ -25,6 +26,10 @@ const createClientsFromProfile = async (): Promise => { if (profile?.services?.remoteStorage) { clients.remoteStorage = await createRemoteStorageClient(profile.services.remoteStorage); } + // initialize authentication + if (profile?.services?.authentication) { + clients.authentication = await createAuthenticationClient(profile.services.authentication); + } return Promise.resolve(clients); }; diff --git a/folio-app/contexts/configuration.tsx b/folio-app/contexts/configuration.tsx new file mode 100644 index 00000000..9fd45ba1 --- /dev/null +++ b/folio-app/contexts/configuration.tsx @@ -0,0 +1,55 @@ +import { useContext, createContext, useState, useEffect } from "react"; +import { Loading } from "folio-react/components/loading.jsx"; +import profile from "@profile"; +import type { JSX, PropsWithChildren } from "react"; + +const ConfigurationContext = createContext(null); + +const getConfigurationFromProfile = (): Promise => { + // 1. check for object + if (typeof profile.config === "object") { + return Promise.resolve(profile.config); + } + // 2. check for promise to resolve configuration + if (typeof profile.config === "function") { + return profile.config(); + } + // 3. return empty object + return Promise.resolve({}); +}; + +// @description hook to get the configuration +export const useConfiguration = (): any => { + return useContext(ConfigurationContext); +}; + +// @description configuration context provider +export const ConfigurationProvider = (props: PropsWithChildren): JSX.Element => { + const [websiteConfig, setWebsiteConfig] = useState(null); + + // on mount, fetch website configuration for the current environment + useEffect(() => { + getConfigurationFromProfile().then(config => { + setWebsiteConfig(config); + }); + }, []); + + // if the website configuration is not yet loaded, we display a loading screen + // to avoid rendering the children before the configuration is available + // this is important because the configuration may contain information that + // is needed to render the children correctly, such as the environment or title + if (!websiteConfig) { + return ( +
+ +
+ ); + } + + // return the configuration context provider with the website configuration + return ( + + {props.children} + + ); +}; diff --git a/folio-app/contexts/toaster.tsx b/folio-app/contexts/toaster.tsx new file mode 100644 index 00000000..233e636e --- /dev/null +++ b/folio-app/contexts/toaster.tsx @@ -0,0 +1,101 @@ +import { createContext, useContext, useState, useMemo, useRef } from "react"; +import classNames from "classnames"; +import { renderIcon } from "@josemi-icons/react"; +import type { JSX, PropsWithChildren } from "react"; + +// amount of time in milliseconds to wait before automatically removing a toast +const TOAST_DURATION = 4000; + +export type Toast = { + message?: string; + icon?: string; // optional icon + iconClassName?: string; // optional icon class name + duration?: number; // duration in milliseconds +}; + +export type ActiveToast = { + data: Toast | null; + visible: boolean; +}; + +// export the type to display a new toast +export type Toaster = { + success: (message: string) => void; + error: (message: string) => void; + dismiss: () => void; +}; + +// toaster context +export const ToasterContext = createContext({} as Toaster); + +// hook to display a toast message +export const useToaster = (): Toaster => { + return useContext(ToasterContext); +}; + +// Toast component +export const ToastComponent = ({ toast }: { toast: Toast }): JSX.Element => { + const toastClass = classNames({ + "flex items-start gap-2 px-4 py-4 rounded-lg shadow-sm": true, + "bg-gray-950 text-white": true, + }); + + return ( +
+ {toast?.icon && ( +
+ {renderIcon(toast.icon)} +
+ )} +
+
+ {toast?.message || ""} +
+
+
+ ); +}; + +// ToasterProvider component +export const ToasterProvider = (props: PropsWithChildren): JSX.Element => { + const [ toast, setToast ] = useState({ data: null, visible: false }); + const toastTimmer = useRef(null); + + // Function to add a toast and automatically execute the removal after its duration + const toaster = useMemo(() => { + const createToast = (toast: Toast) => { + clearTimeout(toastTimmer.current); // clear previous timer if exists + setToast({ data: toast, visible: true }); + + // automatically remove the toast after its duration + toastTimmer.current = setTimeout(() => { + setToast({ visible: false, data: toast }); + }, toast?.duration || TOAST_DURATION); + }; + return { + success: (message: string) => { + createToast({ message, icon: "check-circle" }); + }, + error: (message: string) => { + createToast({ message, icon: "x-circle" }); + }, + dismiss: () => { + clearTimeout(toastTimmer.current); + setToast({ visible: false, data: null }); + }, + } as Toaster; + }, [ setToast ]); + + return ( + + {props.children} + {toast?.data && ( +
+
+ +
+
+ )} +
+ ); +}; diff --git a/folio-app/index.tsx b/folio-app/index.tsx index f5bee10f..0e8c6f7b 100644 --- a/folio-app/index.tsx +++ b/folio-app/index.tsx @@ -1,11 +1,17 @@ import { createRoot } from "react-dom/client"; import { App } from "./components/app.tsx"; +import { ConfigurationProvider } from "./contexts/configuration.tsx"; import { ClientsProvider } from "./contexts/clients.tsx"; +import { ToasterProvider } from "./contexts/toaster.tsx"; createRoot(document.getElementById("root") as HTMLDivElement).render((
- - - + + + + + + +
)); diff --git a/folio-app/lib/api.ts b/folio-app/lib/api.ts index 8095a41b..0fe986aa 100644 --- a/folio-app/lib/api.ts +++ b/folio-app/lib/api.ts @@ -1,3 +1,5 @@ +import { TOKEN_KEY } from "../constants.ts"; + export type ApiClient = (method: string, path: string, data?: any) => Promise; // internal method to reject the promise with a standardized error format @@ -11,7 +13,6 @@ const rejectResponse = (response: any): Promise => { export const createApiClient = (host: string = ""): ApiClient => { return (method: string, path: string, data?: any): Promise => { - const requestUrl = new URL(path, host).href; // construct the URL based on the base URL and path const options: RequestInit = { method: method || "GET", @@ -20,15 +21,16 @@ export const createApiClient = (host: string = ""): ApiClient => { }, }; // include the Authorization header if token is set - // if (token) { - // (options.headers as Record)["Authorization"] = `Bearer ${token}`; - // } + const token = window.localStorage.getItem(TOKEN_KEY) || null; + if (token) { + (options.headers as Record)["Authorization"] = `Bearer ${token}`; + } // include the body if method is POST or PATCH and data is provided if (data && (method === "POST" || method === "PATCH" || method === "PUT")) { options.body = JSON.stringify(data); } // perform the request - return fetch(requestUrl, options) + return fetch(host + path, options) .then(response => { // if the response is not ok, we throw an error if (!response.ok) { diff --git a/folio-app/profiles/development.ts b/folio-app/profiles/development.ts index d9fad89d..76e48c1d 100644 --- a/folio-app/profiles/development.ts +++ b/folio-app/profiles/development.ts @@ -8,12 +8,15 @@ export default { return Promise.resolve({}); }, services: { - localStorage: { - dbName: "folio", - storeName: "folio-store", - initialize: async (store: UseStore) => { - // TODO: initialize storage - }, + // localStorage: { + // dbName: "folio", + // storeName: "folio-store", + // initialize: async (store: UseStore) => { + // // TODO: initialize storage + // }, + // }, + authentication: { + host: "/_auth", }, }, } as Profile; diff --git a/folio-app/types/clients.ts b/folio-app/types/clients.ts index 56c28544..0908ebf8 100644 --- a/folio-app/types/clients.ts +++ b/folio-app/types/clients.ts @@ -1,3 +1,7 @@ +export type Credentials = { + token?: string; +}; + export type Document = { name?: string; id?: string; @@ -5,7 +9,6 @@ export type Document = { metadata?: object; }; -// general interface to access to storage export interface StorageClient { list(): Promise; get(id: string): Promise; @@ -14,3 +17,8 @@ export interface StorageClient { delete(id: string): Promise; }; +export interface AuthenticationClient { + login(credentials: Credentials): Promise; + logout(): Promise; + isAuthenticated(): Promise; +}; diff --git a/folio-app/types/profile.ts b/folio-app/types/profile.ts index ed1dfe33..6efdbd67 100644 --- a/folio-app/types/profile.ts +++ b/folio-app/types/profile.ts @@ -1,5 +1,9 @@ import type { UseStore } from "idb-keyval"; +export type AuthenticationConfig = { + host: string; +}; + export type LocalStorageConfig = { dbName: string; storeName: string; @@ -16,5 +20,6 @@ export type Profile = { services?: { localStorage?: LocalStorageConfig; remoteStorage?: RemoteStorageConfig; + authentication?: AuthenticationConfig; } }; From fddf54175277b2eec456f12ca5fe586848984338 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 12 Jul 2026 20:21:15 +0200 Subject: [PATCH 21/58] Improve webpack server script --- scripts/webpack-server.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/webpack-server.js b/scripts/webpack-server.js index af5cd79c..63efe798 100644 --- a/scripts/webpack-server.js +++ b/scripts/webpack-server.js @@ -45,10 +45,10 @@ export const createServer = (rules = []) => { response.setHeader(name, headers[name]); }); // 2.1 send JSON data calling the provided function - if (rule.response?.fn) { - rule.response.fn(request).then(data => { - response.status(rule.response?.status || 200); - response.json(data); + if (typeof rule.response === "function") { + rule.response(request).then(ruleResponse => { + response.status(ruleResponse?.status || 200); + response.json(ruleResponse?.body || {}); }); } // 2.2. if the rule has a data property, use it to send the data object From b6fa2439d75172e5ef7fec7edd4083e8c7fe2a0f Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 12 Jul 2026 20:22:32 +0200 Subject: [PATCH 22/58] Rename folio-app -> app --- {folio-app => app}/.gitignore | 0 {folio-app => app}/__stubs/authentication.js | 0 {folio-app => app}/__stubs/rules.json | 0 {folio-app => app}/__stubs/storage.json | 0 {folio-app => app}/clients/authentication.ts | 0 {folio-app => app}/clients/storage.ts | 0 {folio-app => app}/components/app.tsx | 0 {folio-app => app}/components/dashboard.tsx | 0 {folio-app => app}/components/editor.tsx | 0 {folio-app => app}/components/login.tsx | 0 {folio-app => app}/components/welcome.tsx | 0 {folio-app => app}/constants.ts | 0 {folio-app => app}/contexts/clients.tsx | 0 {folio-app => app}/contexts/configuration.tsx | 0 {folio-app => app}/contexts/toaster.tsx | 0 {folio-app => app}/index.tsx | 0 {folio-app => app}/lib/api.ts | 0 {folio-app => app}/package.json | 0 {folio-app => app}/profiles/development.ts | 0 {folio-app => app}/profiles/lite.ts | 0 {folio-app => app}/template.html | 0 {folio-app => app}/tsconfig.json | 0 {folio-app => app}/types/clients.ts | 0 {folio-app => app}/types/profile.ts | 0 {folio-app => app}/webpack.config.js | 0 package.json | 2 +- 26 files changed, 1 insertion(+), 1 deletion(-) rename {folio-app => app}/.gitignore (100%) rename {folio-app => app}/__stubs/authentication.js (100%) rename {folio-app => app}/__stubs/rules.json (100%) rename {folio-app => app}/__stubs/storage.json (100%) rename {folio-app => app}/clients/authentication.ts (100%) rename {folio-app => app}/clients/storage.ts (100%) rename {folio-app => app}/components/app.tsx (100%) rename {folio-app => app}/components/dashboard.tsx (100%) rename {folio-app => app}/components/editor.tsx (100%) rename {folio-app => app}/components/login.tsx (100%) rename {folio-app => app}/components/welcome.tsx (100%) rename {folio-app => app}/constants.ts (100%) rename {folio-app => app}/contexts/clients.tsx (100%) rename {folio-app => app}/contexts/configuration.tsx (100%) rename {folio-app => app}/contexts/toaster.tsx (100%) rename {folio-app => app}/index.tsx (100%) rename {folio-app => app}/lib/api.ts (100%) rename {folio-app => app}/package.json (100%) rename {folio-app => app}/profiles/development.ts (100%) rename {folio-app => app}/profiles/lite.ts (100%) rename {folio-app => app}/template.html (100%) rename {folio-app => app}/tsconfig.json (100%) rename {folio-app => app}/types/clients.ts (100%) rename {folio-app => app}/types/profile.ts (100%) rename {folio-app => app}/webpack.config.js (100%) diff --git a/folio-app/.gitignore b/app/.gitignore similarity index 100% rename from folio-app/.gitignore rename to app/.gitignore diff --git a/folio-app/__stubs/authentication.js b/app/__stubs/authentication.js similarity index 100% rename from folio-app/__stubs/authentication.js rename to app/__stubs/authentication.js diff --git a/folio-app/__stubs/rules.json b/app/__stubs/rules.json similarity index 100% rename from folio-app/__stubs/rules.json rename to app/__stubs/rules.json diff --git a/folio-app/__stubs/storage.json b/app/__stubs/storage.json similarity index 100% rename from folio-app/__stubs/storage.json rename to app/__stubs/storage.json diff --git a/folio-app/clients/authentication.ts b/app/clients/authentication.ts similarity index 100% rename from folio-app/clients/authentication.ts rename to app/clients/authentication.ts diff --git a/folio-app/clients/storage.ts b/app/clients/storage.ts similarity index 100% rename from folio-app/clients/storage.ts rename to app/clients/storage.ts diff --git a/folio-app/components/app.tsx b/app/components/app.tsx similarity index 100% rename from folio-app/components/app.tsx rename to app/components/app.tsx diff --git a/folio-app/components/dashboard.tsx b/app/components/dashboard.tsx similarity index 100% rename from folio-app/components/dashboard.tsx rename to app/components/dashboard.tsx diff --git a/folio-app/components/editor.tsx b/app/components/editor.tsx similarity index 100% rename from folio-app/components/editor.tsx rename to app/components/editor.tsx diff --git a/folio-app/components/login.tsx b/app/components/login.tsx similarity index 100% rename from folio-app/components/login.tsx rename to app/components/login.tsx diff --git a/folio-app/components/welcome.tsx b/app/components/welcome.tsx similarity index 100% rename from folio-app/components/welcome.tsx rename to app/components/welcome.tsx diff --git a/folio-app/constants.ts b/app/constants.ts similarity index 100% rename from folio-app/constants.ts rename to app/constants.ts diff --git a/folio-app/contexts/clients.tsx b/app/contexts/clients.tsx similarity index 100% rename from folio-app/contexts/clients.tsx rename to app/contexts/clients.tsx diff --git a/folio-app/contexts/configuration.tsx b/app/contexts/configuration.tsx similarity index 100% rename from folio-app/contexts/configuration.tsx rename to app/contexts/configuration.tsx diff --git a/folio-app/contexts/toaster.tsx b/app/contexts/toaster.tsx similarity index 100% rename from folio-app/contexts/toaster.tsx rename to app/contexts/toaster.tsx diff --git a/folio-app/index.tsx b/app/index.tsx similarity index 100% rename from folio-app/index.tsx rename to app/index.tsx diff --git a/folio-app/lib/api.ts b/app/lib/api.ts similarity index 100% rename from folio-app/lib/api.ts rename to app/lib/api.ts diff --git a/folio-app/package.json b/app/package.json similarity index 100% rename from folio-app/package.json rename to app/package.json diff --git a/folio-app/profiles/development.ts b/app/profiles/development.ts similarity index 100% rename from folio-app/profiles/development.ts rename to app/profiles/development.ts diff --git a/folio-app/profiles/lite.ts b/app/profiles/lite.ts similarity index 100% rename from folio-app/profiles/lite.ts rename to app/profiles/lite.ts diff --git a/folio-app/template.html b/app/template.html similarity index 100% rename from folio-app/template.html rename to app/template.html diff --git a/folio-app/tsconfig.json b/app/tsconfig.json similarity index 100% rename from folio-app/tsconfig.json rename to app/tsconfig.json diff --git a/folio-app/types/clients.ts b/app/types/clients.ts similarity index 100% rename from folio-app/types/clients.ts rename to app/types/clients.ts diff --git a/folio-app/types/profile.ts b/app/types/profile.ts similarity index 100% rename from folio-app/types/profile.ts rename to app/types/profile.ts diff --git a/folio-app/webpack.config.js b/app/webpack.config.js similarity index 100% rename from folio-app/webpack.config.js rename to app/webpack.config.js diff --git a/package.json b/package.json index 93892cde..dd13fcab 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "author": "Josemi Juanes ", "bugs": "https://github.com/jmjuanes/folio/issues", "workspaces": [ - "folio-app", + "app", "folio-react", "workers/*" ], From 8cda4ee11ecaaa138dbbd6e22bb545d87cb7c252 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 26 Jul 2026 20:27:18 +0200 Subject: [PATCH 23/58] Update dependencies to support css-modules in folio app --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index dd13fcab..bc0dd647 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "clean-css": "^5.3.3", "copy-webpack-plugin": "^14.0.0", "cross-env": "^10.1.0", + "css-loader": "^7.1.4", "highlight.js": "^11.11.1", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", @@ -94,6 +95,7 @@ "purgecss": "^8.0.0", "react-test-renderer": "^19.2.7", "serve": "^14.2.4", + "style-loader": "^4.0.0", "ts-jest": "^29.4.9", "typescript": "^6.0.3", "webpack": "^5.107.2", From a319981eb5707fe6dc4f241fbbf5fa44eb0ec975 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 26 Jul 2026 20:27:36 +0200 Subject: [PATCH 24/58] Add support for css modules --- app/webpack.config.js | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/app/webpack.config.js b/app/webpack.config.js index 02403bf6..8c7b3623 100644 --- a/app/webpack.config.js +++ b/app/webpack.config.js @@ -4,8 +4,9 @@ import CopyWebpackPlugin from "copy-webpack-plugin"; import HtmlWebpackPlugin from "html-webpack-plugin"; import { createServer } from "../scripts/webpack-server.js"; +import authenticationRules from "./__stubs/authentication.js"; +import storageRules from "./__stubs/storage.js"; import pkg from "../package.json" with { type: "json" }; -// import rules from "./__stubs/rules.json" with { type: "json" }; export default { mode: process.env.NODE_ENV || "development", @@ -43,7 +44,10 @@ export default { // {from: /^\/index.html$/, to: "app.html"}, ], }, - // setupMiddlewares: createServer(rules), + setupMiddlewares: createServer([ + ...authenticationRules, + ...storageRules, + ]), devMiddleware: { writeToDisk: true, }, @@ -78,6 +82,20 @@ export default { test: /\.(png|jpg|jpeg|svg)$/, type: "asset/inline", }, + { + test: /\.css$/, + use: [ + "style-loader", + { + loader: "css-loader", + options: { + modules: { + localIdentName: "[name]__[local]___[hash:base64:5]", + }, + }, + }, + ], + }, ], }, plugins: [ From 5af29edb9191ddd88e2342c8c38d9f1ef5312fca Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 26 Jul 2026 20:27:57 +0200 Subject: [PATCH 25/58] Add authentication provider for folio-app --- app/contexts/authentication.tsx | 57 +++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 app/contexts/authentication.tsx diff --git a/app/contexts/authentication.tsx b/app/contexts/authentication.tsx new file mode 100644 index 00000000..b05ed64f --- /dev/null +++ b/app/contexts/authentication.tsx @@ -0,0 +1,57 @@ +import { useState, useEffect, createContext, useContext } from "react"; +import { Outlet, useNavigate } from "react-router"; +import { Loading } from "folio-react/components/loading.jsx"; +import { useClients } from "./clients.tsx"; +import { TOKEN_KEY } from "../constants.ts"; +import { loadingWrapper } from "../index.css"; + +import type { JSX } from "react"; +import type { AuthenticatedUser } from "../types/clients.ts"; + +// authentication context +export const AuthenticationContext = createContext({} as AuthenticatedUser); + +// hook to access to authentication +export const useAutenticatedUser = (): any => { + return useContext(AuthenticationContext); +}; + +// main authentication provider +export const AuthenticationProvider = (): JSX.Element => { + const [ authenticatedUser, setAuthenticatedUser] = useState(null); + const clients = useClients(); + const navigate = useNavigate(); + + // get token from local storage + // this will ensure to validate the token when it changes + const token = window.localStorage.getItem(TOKEN_KEY); + + useEffect(() => { + setAuthenticatedUser(null); + if (clients.authentication) { + // TODO: check that the token exists and we can fetch the information about the authenticated user + clients.authentication?.getAuthenticatedUser() + .then((data: AuthenticatedUser) => { + setAuthenticatedUser(data); + }) + .catch((error: any) => { + console.error(error); + clients.authentication?.logout(); + navigate("/login"); + }); + } + }, [clients.authentication, navigate, setAuthenticatedUser, token]); + + // if service is defined and is not initialized, display a loading screen + if (!clients || !authenticatedUser) { + return ( + + ); + } + + return ( + + + + ); +}; From e3f4c9082d4834eb634305d5a3cfc0649ff6637c Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 26 Jul 2026 20:28:15 +0200 Subject: [PATCH 26/58] Initialize types definitions --- app/types/index.d.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/types/index.d.ts diff --git a/app/types/index.d.ts b/app/types/index.d.ts new file mode 100644 index 00000000..ce812a65 --- /dev/null +++ b/app/types/index.d.ts @@ -0,0 +1,3 @@ +declare module "*.css"; +declare module "folio-react/*"; +declare module "@josemi-icons/react"; From 48d0bcf6ea45f51ec09738f5d691c82f0f80378c Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 26 Jul 2026 20:28:43 +0200 Subject: [PATCH 27/58] Migrate login component to css modules --- app/components/login.tsx | 50 ++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/app/components/login.tsx b/app/components/login.tsx index 4b456d40..5a8d4df3 100644 --- a/app/components/login.tsx +++ b/app/components/login.tsx @@ -6,21 +6,23 @@ import { Centered } from "folio-react/components/ui/centered.tsx"; import { useToaster } from "../contexts/toaster.tsx"; import { useClients } from "../contexts/clients.tsx"; import { useConfiguration } from "../contexts/configuration.tsx"; +import * as styles from "../index.css"; + import type { JSX } from "react"; -const LoginMessageAlert = ({ title, content }: { title: string, content: string }): JSX.Element => { - return ( -
-
-
- -
-
{title}
-
-
{content}
-
- ); -}; +// const LoginMessageAlert = ({ title, content }: { title: string, content: string }): JSX.Element => { +// return ( +//
+//
+//
+// +//
+//
{title}
+//
+//
{content}
+//
+// ); +// }; // @description login component export const Login = (): JSX.Element => { @@ -50,18 +52,18 @@ export const Login = (): JSX.Element => { }, [authentication]); return ( - -
-
+ +
+
{configuration?.login_title || configuration?.title || "folio."}
-
+
You need to log in with your access token to continue.
-
+
{ } }} /> -
+
Your access token is printed in the terminal where you run the server. If you don't have it, please contact your administrator.
+ {/* {(configuration?.login_messages || []).map((message: any, index: number) => ( { content={message.content} /> ))} -
-
From 80958d0bb83459723e3e78c346b625bbd1b108f9 Mon Sep 17 00:00:00 2001 From: Josemi Date: Tue, 28 Jul 2026 18:47:21 +0200 Subject: [PATCH 28/58] Migrate toaster component --- app/contexts/toaster.tsx | 52 ++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/app/contexts/toaster.tsx b/app/contexts/toaster.tsx index 233e636e..1984bca6 100644 --- a/app/contexts/toaster.tsx +++ b/app/contexts/toaster.tsx @@ -1,15 +1,20 @@ import { createContext, useContext, useState, useMemo, useRef } from "react"; import classNames from "classnames"; import { renderIcon } from "@josemi-icons/react"; -import type { JSX, PropsWithChildren } from "react"; +import * as styles from "../index.css"; + +import type { JSX, PropsWithChildren, CSSProperties } from "react"; // amount of time in milliseconds to wait before automatically removing a toast const TOAST_DURATION = 4000; export type Toast = { message?: string; + messageClassName?: string; + messageStyle?: CSSProperties; icon?: string; // optional icon iconClassName?: string; // optional icon class name + iconStyle?: CSSProperties; duration?: number; // duration in milliseconds }; @@ -34,27 +39,20 @@ export const useToaster = (): Toaster => { }; // Toast component -export const ToastComponent = ({ toast }: { toast: Toast }): JSX.Element => { - const toastClass = classNames({ - "flex items-start gap-2 px-4 py-4 rounded-lg shadow-sm": true, - "bg-gray-950 text-white": true, - }); - - return ( -
- {toast?.icon && ( -
- {renderIcon(toast.icon)} -
- )} -
-
- {toast?.message || ""} -
+export const ToastComponent = ({ toast }: { toast: Toast }): JSX.Element => ( +
+ {toast?.icon && ( +
+ {renderIcon(toast.icon)} +
+ )} +
+
+ {toast?.message || ""}
- ); -}; +
+); // ToasterProvider component export const ToasterProvider = (props: PropsWithChildren): JSX.Element => { @@ -81,7 +79,9 @@ export const ToasterProvider = (props: PropsWithChildren): JSX.Element => { }, dismiss: () => { clearTimeout(toastTimmer.current); - setToast({ visible: false, data: null }); + setToast(prevToast => { + return Object.assign({}, prevToast, { visible: false }); + }); }, } as Toaster; }, [ setToast ]); @@ -89,13 +89,13 @@ export const ToasterProvider = (props: PropsWithChildren): JSX.Element => { return ( {props.children} - {toast?.data && ( -
-
+
+
+ {toast?.data && ( -
+ )}
- )} +
); }; From 9c3c0bf45ddf94ffbaaf8a4d5e98bf1b6f65e14c Mon Sep 17 00:00:00 2001 From: Josemi Date: Sun, 23 Aug 2026 20:22:57 +0200 Subject: [PATCH 29/58] Add logo component --- app/components/Logo.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 app/components/Logo.tsx diff --git a/app/components/Logo.tsx b/app/components/Logo.tsx new file mode 100644 index 00000000..b55b6655 --- /dev/null +++ b/app/components/Logo.tsx @@ -0,0 +1,14 @@ +import classNames from "classnames"; +import { logo } from "../styles/components.css"; +import type { CSSProperties, JSX } from "react"; + +export type LogoProps = { + className?: string; + style?: CSSProperties; +}; + +export const Logo = (props: LogoProps): JSX.Element => ( +
+ Folio +
+); From bdb339f2bb8203589b5e5cf05a24c42bd986d906 Mon Sep 17 00:00:00 2001 From: Josemi Date: Mon, 24 Aug 2026 18:36:27 +0200 Subject: [PATCH 30/58] Move sidebar and convert to cssmodules --- app/components/Sidebar.tsx | 328 ++++++++++++++++++++++++++ app/styles/components.css | 178 ++++++++++++++ apps/studio/components/sidebar.tsx | 361 ----------------------------- 3 files changed, 506 insertions(+), 361 deletions(-) create mode 100644 app/components/Sidebar.tsx create mode 100644 app/styles/components.css delete mode 100644 apps/studio/components/sidebar.tsx diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx new file mode 100644 index 00000000..2ee8ee99 --- /dev/null +++ b/app/components/Sidebar.tsx @@ -0,0 +1,328 @@ +import { useEffect, useCallback, useState, useRef, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { useToggle } from "react-use"; +import classNames from "classnames"; +import { renderIcon, DotsIcon } from "@josemi-icons/react"; +import { Dropdown, DropdownPortalPosition } from "folio-react/components/ui/dropdown.tsx"; +// import { useDialog } from "folio-react/contexts/dialogs.tsx"; +// import { useConfirm } from "folio-react/contexts/confirm.jsx"; +import { useConfiguration } from "../contexts/configuration.tsx"; +import { useAppState } from "../contexts/AppState.tsx"; +import { useToaster } from "../contexts/toaster.tsx"; +import { Logo } from "./Logo.tsx"; +// import { RenameDocumentDialog } from "./dialogs/rename-document.tsx"; +import { groupByDate, formatDate } from "../utils/dates.ts"; + +import * as styles from "../styles/components.css"; +import { fontSize2xl, fontFamilyHeading } from "folio-react/styles/utilities.css"; + +import type { JSX, SyntheticEvent } from "react"; + +type ActionButtonProps = { + href?: string; + icon?: string; + text?: string; + collapsed?: boolean; + onClick: (event: SyntheticEvent) => void; +}; + +// @description action button component (create, import, settings, etc.) +const ActionButton = (props: ActionButtonProps): JSX.Element => ( + + {props.icon && ( +
+ {renderIcon(props.icon)} +
+ )} + {!props.collapsed && props.text && ( +
{props.text}
+ )} +
+); + +type DocumentButtonProps = { + id: string; + name: string; + created_at: string; + updated_at: string; +}; + +const DocumentButton = (props: DocumentButtonProps): JSX.Element => { + // const { showDialog } = useDialog(); + // const { showConfirm } = useConfirm(); + const { app } = useAppState(); + const toaster = useToaster(); + + const title = props?.name || "Untitled"; + const active = false; // app.isBoardOpen(props?.id); + + // listener to handle renaming the document + // it will close the actions menu and call the onRename callback if provided + const handleRename = useCallback((event: SyntheticEvent) => { + event.preventDefault(); + // setActionsMenuOpen(false); + // showDialog({ + // component: ({ onClose }) => ( + // + // ), + // dialogClassName: "w-full max-w-sm", + // }); + }, [props.id]); + + // listener to save a local copy of the document + // it will close the actions menu and execute the save as action + const handleSaveAs = useCallback((event: SyntheticEvent) => { + event.preventDefault(); + // setActionsMenuOpen(false); + // app.downloadDocument(props.id) + // .then(() => { + // toaster.success("Document saved."); + // }) + // .catch(error => { + // console.error(error); + // toaster.error(error?.message || "An error occurred while saving the document."); + // }); + }, [props.id]); + + // listener to handle duplicating the document + const handleDuplicate = useCallback((event: SyntheticEvent) => { + event.preventDefault(); + // setActionsMenuOpen(false); + // app.duplicateDocument(props.id) + // .then((newDocument) => { + // app.refresh(); + // toaster.success(`Duplicate saved as '${newDocument?.name || "Untitled"}'.`); + // }) + // .catch(error => { + // console.error(error); + // toaster.error(error?.message || "An error occurred while duplicating the document."); + // }); + }, [props.id]); + + // listener to handle deletion of a document + // it will close the actions menu and call the onDelete callback if provided + const handleDelete = useCallback((event: SyntheticEvent) => { + event.preventDefault(); + // setActionsMenuOpen(false); + // showConfirm({ + // title: "Delete Document", + // message: `Are you sure you want to delete this document? This action cannot be undone.`, + // confirmText: "Yes, Delete", + // callback: () => { + // app.deleteDocument(props.id) + // .then(() => { + // // if the deleted board is the current one, redirect to the home page + // if (active) { + // app.openHome(); + // } + // app.refresh(); + // toaster.success("Document deleted successfully."); + // }) + // .catch(error => { + // console.error(error); + // toaster.error(error?.message || "An error occurred while deleting the document."); + // }); + // }, + // }); + }, [props.id, active, app]); + + const itemClass = classNames(styles.sidebarDocumentButton, { + [styles.sidebarDocumentButtonActive]: active, + }); + + return ( + +
+
+ {renderIcon("file")} +
+
+ {title} +
+
+ ( +
+ +
+ )} + contentStyle={{ + position: "fixed", + zIndex: "50", + }} + contentRender={() => ( + + + + Rename + + + + Save a Copy + + + + Duplicate + + + + + Delete + + + {/* +
+
Created {formatDate(props.created_at)}.
+
Last edited {formatDate(props.updated_at)}.
+
+ */} +
+ )} + /> +
+ ); +}; + +const Group = ({ title, items }: { title: string, items: any[] }): JSX.Element => ( +
+
+
{title || ""}
+
+ {(items || []).map((item: any, index: number) => ( + + ))} +
+); + +// @description separator for the sidebar +// const Separator = (): React.JSX.Element => ( +//
+// ); + +// export the sidebar component +export const Sidebar = (): JSX.Element => { + const { app } = useAppState(); + const [collapsed, toggleCollapsed] = useToggle(false); + const websiteConfig = useConfiguration(); + const sidebarClass = classNames({ + [styles.sidebar]: true, + [styles.sidebarCollapsed]: collapsed, + [styles.sidebarExpanded]: !collapsed, + }); + + // note that this event will not be triggered if the sidebar is collapsed + const handleToggleCollapsed = useCallback(() => { + if (collapsed) { + toggleCollapsed(); + } + }, [collapsed, toggleCollapsed]); + + // group boards by the updated_at field + const groups = useMemo(() => { + return groupByDate(app.documents || [], "updated_at"); + }, [app.documents]); + + return ( +
+
+
+ {!collapsed && ( + + )} +
+
+ { + event.stopPropagation(); + toggleCollapsed(); + }} + icon={collapsed ? "sidebar-left-open" : "sidebar-left-close"} + /> +
+
+
+
+ { + event.stopPropagation(); + // app.createDocument(Collection.BOARD, {}).then((board: any) => { + // app.openBoard(board.id); + // app.refresh(); + // }); + }} + collapsed={collapsed} + icon="plus" + text="Create Document" + /> + { + event.stopPropagation(); + // app.importDocument().then((board: any) => { + // app.openBoard(board.id); + // app.refresh(); + // }); + }} + collapsed={collapsed} + icon="upload" + text="Import Document" + /> +
+ {!collapsed && groups.today.length > 0 && ( + + )} + {!collapsed && groups.yesterday.length > 0 && ( + + )} + {!collapsed && groups.thisWeek.length > 0 && ( + + )} + {!collapsed && groups.thisMonth.length > 0 && ( + + )} + {!collapsed && groups.others.length > 0 && ( + + )} +
+
+ { + event.stopPropagation(); + // app.logout(); + }} + collapsed={collapsed} + icon="logout" + text="Sign out" + /> +
+
+ ); +}; diff --git a/app/styles/components.css b/app/styles/components.css new file mode 100644 index 00000000..268834f3 --- /dev/null +++ b/app/styles/components.css @@ -0,0 +1,178 @@ +@import url("../../folio-react/styles/variables.css"); + +/* login styles */ +.login { + width: var(--folio-container-sm); +} +.loginTitle { + font-size: var(--folio-fontSize-5xl); + font-weight: 700; + line-height: 1; + margin-bottom: 1rem; + user-select: none; +} +.loginDescription { + font-size: var(--folio-fontSize-sm); + margin-bottom: 1rem; +} + +/* logo component */ +.logo { + letter-spacing: -0.1rem; + line-height: 1; + user-select: none; +} + +/* sidebar component */ +.sidebar { + border-right: solid 1px var(--folio-color-border); + display: flex; + flex-direction: column; + flex-shrink: 0; + height: 100%; + justify-content: space-between; + padding: 0.5rem; + transition: width 0.25s ease-in-out; +} +.sidebarCollapsed { + cursor: e-resize; + width: 5rem; +} +.sidebarExpanded { + width: 16rem; +} +.sidebarHeader { + align-items: center; + display: flex; + flex-wrap: nowrap; + justify-content: space-between; + padding: 0.75rem; + position: sticky; + top: 0; + user-select: none;; + z-index: 50; +} +.sidebarBody { + display: flex; + flex-direction: column; + gap: 1rem; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + padding: 0 0.75rem 0 0.75rem; +} +.sidebarFooter { + padding: 0.75rem; +} + +/* action button displayed in the sidebar */ +.sidebarActionButton { + align-items: center; + border-radius: var(--folio-radii-md); + cursor: pointer; + display: flex; + flex-wrap: nowrap; + gap: 0.5rem; + height: 2rem; + overflow: hidden; + padding: 0.25rem 0.5rem; +} +.sidebarActionButton:hover { + background-color: var(--folio-color-accent); +} +.sidebarActionButtonIcon { + align-items: center; + display: flex; + flex-shrink: 0; + font-size: var(--folio-fontSize-xl); + justify-content: center; + width: 1.5rem; +} +.sidebarActionButtonText { + flex-shrink: 0; + font-size: var(--folio-fontSize-sm); + font-weight: 500; +} +/* wrapper to display action buttons */ +.sidebarActionButtons { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 0.5rem; +} + +/* sidebar document button */ +.sidebarDocumentButton { + align-items: center; + border-radius: var(--folio-radii-md); + display: flex; + padding: 0.25rem 0.5rem; + position: relative; + width: 100%; +} +.sidebarDocumentButton:hover, +.sidebarDocumentButtonActive { + background-color: var(--folio-color-accent); +} +.sidebarDocumentButtonContent { + align-items: center; + cursor: pointer; + display: flex; + gap: 0.5rem; + overflow: hidden; + width: 100%; +} +.sidebarDocumentButtonContentIcon { + align-items: center; + display: flex; + flex-shrink: 0; + font-size: var(--folio-fontSize-lg); +} +.sidebarDocumentButtonContentText { + flex-shrink: 0; + font-size: var(--folio-fontSize-sm); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 8rem; +} +.sidebarDocumentButtonAction { + align-items: center; + cursor: pointer; + display: flex; + margin-left: auto; + opacity: 0; + padding: 0; +} +.sidebarDocumentButton:hover > .sidebarDocumentButtonAction { + opacity: 1; +} +.sidebarDocumentButtonActionIcon { + align-items: center; + border-radius: var(--folio-radii-sm); + display: flex; + padding: 0.25rem; +} +.sidebarDocumentButtonActionItem:hover { + background-color: var(--folio-color-border); +} +.sidebarDocumentButtonPopover { + width: 12rem; +} + +/* sidebar group */ +.sidebarGroup { + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.sidebarGroupTitle { + color: var(--folio-color-muted); + display: flex; + font-size: var(--folio-fontSize-xs); + font-weight: 700; + padding: 0 0.5rem; + overflow: hidden; +} + diff --git a/apps/studio/components/sidebar.tsx b/apps/studio/components/sidebar.tsx deleted file mode 100644 index e295b0be..00000000 --- a/apps/studio/components/sidebar.tsx +++ /dev/null @@ -1,361 +0,0 @@ -import React from "react"; -import { createPortal } from "react-dom"; -import { useToggle } from "react-use"; -import classNames from "classnames"; -import { renderIcon, DotsIcon } from "@josemi-icons/react"; -import { Collection } from "folio-server/types/document.ts"; -import { Dropdown } from "folio-react/components/ui/dropdown.tsx"; -import { useDialog } from "folio-react/contexts/dialogs.tsx"; -import { useConfirm } from "folio-react/contexts/confirm.jsx"; -import { useConfiguration } from "../contexts/configuration.tsx"; -import { useAppState } from "../contexts/app-state.tsx"; -import { useToaster } from "../contexts/toaster.tsx"; -import { RenameDocumentDialog } from "./dialogs/rename-document.tsx"; -import { groupByDate, formatDate } from "../utils/dates.ts"; - -type ActionButtonProps = { - href?: string; - icon?: string; - text?: string; - collapsed?: boolean; - onClick?: (event?: React.SyntheticEvent) => void; -}; - -// @description action button component (create, import, settings, etc.) -const ActionButton = ({ href, icon, text = "", collapsed = false, onClick }: ActionButtonProps): React.JSX.Element => { - const actionClass = classNames({ - "h-8 px-2 py-1": true, - "flex items-center flex-nowrap gap-2 cursor-pointer rounded-md overflow-hidden": true, - "hover:bg-gray-200 text-gray-700 hover:text-gray-950": true, - }); - return ( - - {icon && ( -
- {renderIcon(icon)} -
- )} - {!collapsed && text && ( -
{text}
- )} -
- ); -}; - -type DocumentButtonProps = { - id: string; - name: string; - created_at: string; - updated_at: string; -}; - -const DocumentButton = (props: DocumentButtonProps): React.JSX.Element => { - const [actionsMenuOpen, setActionsMenuOpen] = React.useState(false); - const actionsMenuRef = React.useRef(null); - const position = React.useRef({}); - const { showDialog } = useDialog(); - const { showConfirm } = useConfirm(); - const { app } = useAppState(); - const toaster = useToaster(); - - const title = props?.name || "Untitled"; - const active = app.isBoardOpen(props?.id); - - // when clicking on the action item, open the actions menu - // and position it below the clicked item - const handleActionsMenuClick = React.useCallback((event: React.SyntheticEvent) => { - event.preventDefault(); - if (event.currentTarget) { - const rect = event.currentTarget.getBoundingClientRect(); - position.current = { - top: rect.bottom + window.scrollY, - left: rect.left + window.scrollX, - }; - setActionsMenuOpen(true); - } - }, [setActionsMenuOpen]); - - // listener to handle renaming the document - // it will close the actions menu and call the onRename callback if provided - const handleRename = React.useCallback((event: React.SyntheticEvent) => { - event.preventDefault(); - setActionsMenuOpen(false); - showDialog({ - component: ({ onClose }) => ( - - ), - dialogClassName: "w-full max-w-sm", - }); - }, [props.id, setActionsMenuOpen, showDialog]); - - // listener to save a local copy of the document - // it will close the actions menu and execute the save as action - const handleSaveAs = React.useCallback((event: React.SyntheticEvent) => { - event.preventDefault(); - setActionsMenuOpen(false); - app.saveDocument(props.id) - .then(() => { - toaster.success("Document saved."); - }) - .catch(error => { - console.error(error); - toaster.error(error?.message || "An error occurred while saving the document."); - }); - }, [props.id, setActionsMenuOpen]); - - // listener to handle duplicating the document - const handleDuplicate = React.useCallback((event: React.SyntheticEvent) => { - event.preventDefault(); - setActionsMenuOpen(false); - app.duplicateDocument(props.id) - .then((newDocument) => { - app.refresh(); - toaster.success(`Duplicate saved as '${newDocument?.name || "Untitled"}'.`); - }) - .catch(error => { - console.error(error); - toaster.error(error?.message || "An error occurred while duplicating the document."); - }); - }, [props.id, setActionsMenuOpen]); - - // listener to handle deletion of a document - // it will close the actions menu and call the onDelete callback if provided - const handleDelete = React.useCallback((event: React.SyntheticEvent) => { - event.preventDefault(); - setActionsMenuOpen(false); - showConfirm({ - title: "Delete Document", - message: `Are you sure you want to delete this document? This action cannot be undone.`, - confirmText: "Yes, Delete", - callback: () => { - app.deleteDocument(props.id) - .then(() => { - // if the deleted board is the current one, redirect to the home page - if (active) { - app.openHome(); - } - app.refresh(); - toaster.success("Document deleted successfully."); - }) - .catch(error => { - console.error(error); - toaster.error(error?.message || "An error occurred while deleting the document."); - }); - }, - }); - }, [props.id, setActionsMenuOpen, active, app]); - - React.useEffect(() => { - if (actionsMenuOpen) { - const handleClickOutside = (event: Event) => { - event.preventDefault(); - if (actionsMenuRef.current && !actionsMenuRef.current.contains(event.target)) { - setActionsMenuOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - } - }, [actionsMenuOpen]); - - const itemClass = classNames({ - "relative group rounded-md w-full flex items-center py-1 px-2": true, - "bg-gray-200 text-gray-900": active || actionsMenuOpen, - "hover:bg-gray-200 text-gray-600 hover:text-gray-900": !active, - }); - return ( - -
-
- {renderIcon("file")} -
-
- {title} -
-
-
-
- -
-
- {actionsMenuOpen && createPortal([ -
, -
- - - - Rename - - - - Save a Copy - - - - Duplicate - - - - - Delete - - -
-
Created {formatDate(props.created_at)}.
-
Last edited {formatDate(props.updated_at)}.
-
-
-
, - ], document.body)} -
- ); -}; - -const Group = ({ title, items }): React.JSX.Element => ( -
-
-
{title || ""}
-
- {(items || []).map((item: any, index: number) => ( - - ))} -
-); - -// @description separator for the sidebar -const Separator = (): React.JSX.Element => ( -
-); - -// export the sidebar component -export const Sidebar = (): React.JSX.Element => { - const { app } = useAppState(); - const [collapsed, toggleCollapsed] = useToggle(false); - const websiteConfig = useConfiguration(); - const sidebarClass = classNames({ - "h-full shrink-0 flex flex-col justify-between": true, - "w-16 cursor-e-resize": collapsed, - "w-64": !collapsed, - }); - - // note that this event will not be triggered if the sidebar is collapsed - const handleToggleCollapsed = React.useCallback(() => { - if (collapsed) { - toggleCollapsed(); - } - }, [collapsed, toggleCollapsed]); - - // group boards by the updated_at field - const groups = React.useMemo(() => { - return groupByDate(app.documents || [], "updated_at"); - }, [app.documents]); - - return ( -
-
-
-
- {!collapsed && ( -
{websiteConfig.sidebar_title || websiteConfig.title || "folio."}
- )} -
-
- { - event.stopPropagation(); - toggleCollapsed(); - }} - icon={collapsed ? "sidebar-left-open" : "sidebar-left-close"} - /> -
-
-
-
- { - event.stopPropagation(); - }} - collapsed={collapsed} - href="#home" - icon="home" - text="Home" - /> - { - event.stopPropagation(); - app.createDocument(Collection.BOARD, {}).then((board: any) => { - app.openBoard(board.id); - app.refresh(); - }); - }} - collapsed={collapsed} - icon="plus" - text="Create Document" - /> - { - event.stopPropagation(); - app.importDocument().then((board: any) => { - app.openBoard(board.id); - app.refresh(); - }); - }} - collapsed={collapsed} - icon="upload" - text="Import Document" - /> -
- {!collapsed && groups.today.length > 0 && ( - - )} - {!collapsed && groups.yesterday.length > 0 && ( - - )} - {!collapsed && groups.thisWeek.length > 0 && ( - - )} - {!collapsed && groups.thisMonth.length > 0 && ( - - )} - {!collapsed && groups.others.length > 0 && ( - - )} -
-
-
- { - event.stopPropagation(); - app.logout(); - }} - collapsed={collapsed} - icon="logout" - text="Sign out" - /> -
-
- ); -}; From 1cc52bc544a519f3bb841c0624df9f0246b20da8 Mon Sep 17 00:00:00 2001 From: Josemi Date: Mon, 24 Aug 2026 18:36:45 +0200 Subject: [PATCH 31/58] Initialize storaage stubs --- app/__stubs/storage.js | 57 ++++++++++++++++++++++++++++++++++++++++ app/__stubs/storage.json | 0 2 files changed, 57 insertions(+) create mode 100644 app/__stubs/storage.js delete mode 100644 app/__stubs/storage.json diff --git a/app/__stubs/storage.js b/app/__stubs/storage.js new file mode 100644 index 00000000..7401ec56 --- /dev/null +++ b/app/__stubs/storage.js @@ -0,0 +1,57 @@ +export default [ + { + method: "GET", + path: "/_storage/status", + headers: { + "Content-Type": "application/json" + }, + response: { + body: { + data: { + message: "ok", + } + } + } + }, + { + method: "GET", + path: "/_storage/", + headers: { + "Content-Type": "application/json" + }, + response: { + body: { + data: [ + { + id: "board:board1", + metadata: { + "name": "Board 1", + "created_at": "2025-07-25 00:00:00", + "updated_at": "2025-09-02 00:00:00", + }, + }, + ], + }, + }, + }, + { + method: "GET", + path: "/_storage/board:board1", + headers: { + "Content-Type": "application/json" + }, + response: { + body: { + data: { + id: "board:board1", + value: {}, + metadata: { + "name": "Board 1", + "created_at": "2025-07-25 00:00:00", + "updated_at": "2025-09-02 00:00:00", + }, + }, + }, + }, + }, +]; diff --git a/app/__stubs/storage.json b/app/__stubs/storage.json deleted file mode 100644 index e69de29b..00000000 From c1ea4666c1dc007576882d5b464b6a555e253c33 Mon Sep 17 00:00:00 2001 From: Josemi Date: Mon, 24 Aug 2026 18:37:32 +0200 Subject: [PATCH 32/58] Move login and migrate to cssmodules --- app/components/{login.tsx => Login.tsx} | 27 ++++++-- apps/studio/components/login.tsx | 89 ------------------------- 2 files changed, 20 insertions(+), 96 deletions(-) rename app/components/{login.tsx => Login.tsx} (80%) delete mode 100644 apps/studio/components/login.tsx diff --git a/app/components/login.tsx b/app/components/Login.tsx similarity index 80% rename from app/components/login.tsx rename to app/components/Login.tsx index 5a8d4df3..329be13f 100644 --- a/app/components/login.tsx +++ b/app/components/Login.tsx @@ -1,3 +1,4 @@ +import classNames from "classnames"; import { useState, useCallback, useRef } from "react"; import { useNavigate } from "react-router"; import { ExclamationCircleIcon } from "@josemi-icons/react"; @@ -5,8 +6,12 @@ import { Button } from "folio-react/components/ui/button.tsx"; import { Centered } from "folio-react/components/ui/centered.tsx"; import { useToaster } from "../contexts/toaster.tsx"; import { useClients } from "../contexts/clients.tsx"; -import { useConfiguration } from "../contexts/configuration.tsx"; -import * as styles from "../index.css"; +import { Logo } from "./Logo.tsx"; +// import { useConfiguration } from "../contexts/configuration.tsx"; + +import * as formStyles from "folio-react/styles/form.css"; +import { fontFamilyHeading } from "folio-react/styles/utilities.css"; +import * as styles from "../styles/components.css"; import type { JSX } from "react"; @@ -28,11 +33,14 @@ import type { JSX } from "react"; export const Login = (): JSX.Element => { const navigate = useNavigate(); const toaster = useToaster(); - const configuration = useConfiguration(); + // const configuration = useConfiguration(); const { authentication } = useClients(); const [loading, setLoading] = useState(false); const accessTokenRef = useRef(null); + // get visible title + // const title = configuration?.login_title || configuration?.title || null; + const handleLogin = useCallback(() => { const accessToken = (accessTokenRef.current?.value || "").trim(); if (!accessToken) { @@ -55,15 +63,20 @@ export const Login = (): JSX.Element => {
- {configuration?.login_title || configuration?.title || "folio."} +
You need to log in with your access token to continue.
-
+
{ } }} /> -
+
Your access token is printed in the terminal where you run the server. If you don't have it, please contact your administrator.
diff --git a/apps/studio/components/login.tsx b/apps/studio/components/login.tsx deleted file mode 100644 index aeb25dad..00000000 --- a/apps/studio/components/login.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from "react"; -import { ExclamationCircleIcon } from "@josemi-icons/react"; -import { Button } from "folio-react/components/ui/button.tsx"; -import { Centered } from "folio-react/components/ui/centered.tsx"; -import { useConfiguration } from "../contexts/configuration.tsx"; -import { useToaster } from "../contexts/toaster.tsx"; -import { useClient } from "../contexts/client.tsx"; -import type { LoginMessage } from "../contexts/configuration.tsx"; - -const LoginMessageAlert = ({ title, content }: LoginMessage): React.JSX.Element => { - return ( -
-
-
- -
-
{title}
-
-
{content}
-
- ); -}; - -// @description login component -export const Login = (): React.JSX.Element => { - const toaster = useToaster(); - const client = useClient(); - const websiteConfig = useConfiguration(); - const [loading, setLoading] = React.useState(false); - const accessTokenRef = React.useRef(null); - - const handleLogin = React.useCallback(() => { - const accessToken = (accessTokenRef.current?.value || "").trim(); - if (!accessToken) { - return toaster.error("The access token is required to log in."); - } - - // try to login with the provided access token - setLoading(true); - client.login({ token: accessToken }) - .catch(error => { - toaster.error(error?.message || "An error occurred while logging in."); - }) - .finally(() => setLoading(false)); - }, [client]); - - return ( - -
-
- {websiteConfig.login_title || websiteConfig.title || "folio."} -
-
- You need to log in with your access token to continue. -
-
- { - if (event.key === "Enter") { - event.preventDefault(); - handleLogin(); - } - }} - /> -
- Your access token is printed in the terminal where you run the server. If you don't have it, please contact your administrator. -
-
- {(websiteConfig.login_messages || []).map((message: LoginMessage, index: number) => ( - - ))} -
- -
-
-
- ); -}; From 4a66d535731069465aab627c8ecd4e2bddc9fbc8 Mon Sep 17 00:00:00 2001 From: Josemi Date: Mon, 24 Aug 2026 18:45:41 +0200 Subject: [PATCH 33/58] Add layout base component --- app/components/Layout.tsx | 16 ++++++++++++++++ app/styles/components.css | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 app/components/Layout.tsx diff --git a/app/components/Layout.tsx b/app/components/Layout.tsx new file mode 100644 index 00000000..9e66fb4f --- /dev/null +++ b/app/components/Layout.tsx @@ -0,0 +1,16 @@ +import { Outlet } from "react-router"; +import { Sidebar } from "./Sidebar.tsx"; +import * as styles from "../styles/components.css"; + +import type { JSX } from "react"; + +export const Layout = (): JSX.Element => { + return ( +
+ +
+ +
+
+ ); +}; diff --git a/app/styles/components.css b/app/styles/components.css index 268834f3..d14185d4 100644 --- a/app/styles/components.css +++ b/app/styles/components.css @@ -1,5 +1,21 @@ @import url("../../folio-react/styles/variables.css"); +/* main app layout */ +.layout { + background-color: var(--folio-color-background); + height: 100%; + left: 0; + padding: 0; + /* position: fixed; */ + top: 0; + width: 100%; +} +.layoutContent { + height: 100%; + overflow: hidden; + width: 100%; +} + /* login styles */ .login { width: var(--folio-container-sm); From add525cbb79e2b302fe60a5a19ec7627541b2b8c Mon Sep 17 00:00:00 2001 From: Josemi Date: Mon, 24 Aug 2026 18:52:42 +0200 Subject: [PATCH 34/58] Fix layout styles to place sidebar --- app/styles/components.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/styles/components.css b/app/styles/components.css index d14185d4..4184ac80 100644 --- a/app/styles/components.css +++ b/app/styles/components.css @@ -3,6 +3,9 @@ /* main app layout */ .layout { background-color: var(--folio-color-background); + display: flex; + flex-direction: row; + flex-wrap: nowrap; height: 100%; left: 0; padding: 0; @@ -11,6 +14,7 @@ width: 100%; } .layoutContent { + display: block; height: 100%; overflow: hidden; width: 100%; From afebeb5237e64335203a0dcc62675e0d739c8f9b Mon Sep 17 00:00:00 2001 From: Josemi Date: Mon, 24 Aug 2026 19:18:19 +0200 Subject: [PATCH 35/58] Improve components --- app/components/Home.tsx | 17 +++++++++++++++++ app/components/Login.tsx | 8 +------- app/components/Sidebar.tsx | 9 +-------- app/styles/components.css | 11 +++++++++++ app/styles/fonts.css | 22 ++++++++++++++++++++++ 5 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 app/components/Home.tsx create mode 100644 app/styles/fonts.css diff --git a/app/components/Home.tsx b/app/components/Home.tsx new file mode 100644 index 00000000..b2c74f3e --- /dev/null +++ b/app/components/Home.tsx @@ -0,0 +1,17 @@ +// import classNames from "classnames"; +// import { Fragment } from "react"; +// import { PlusIcon } from "@josemi-icons/react"; +// import { Button } from "folio-react/components/ui/button.tsx"; +import { Centered } from "folio-react/components/ui/centered.tsx"; +// import { EmptyState } from "./EmptyState.tsx"; +// import { useClients } from "../contexts/clients.tsx"; +// import * as styles from "../styles/components.css"; + +import type { JSX } from "react"; + +export const Home = (): JSX.Element => { + // const { remoteStorage } = useClients(); + return ( + + ); +}; diff --git a/app/components/Login.tsx b/app/components/Login.tsx index 329be13f..0ecfe346 100644 --- a/app/components/Login.tsx +++ b/app/components/Login.tsx @@ -10,7 +10,6 @@ import { Logo } from "./Logo.tsx"; // import { useConfiguration } from "../contexts/configuration.tsx"; import * as formStyles from "folio-react/styles/form.css"; -import { fontFamilyHeading } from "folio-react/styles/utilities.css"; import * as styles from "../styles/components.css"; import type { JSX } from "react"; @@ -63,12 +62,7 @@ export const Login = (): JSX.Element => {
- +
You need to log in with your access token to continue. diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 2ee8ee99..56063347 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -1,5 +1,4 @@ import { useEffect, useCallback, useState, useRef, useMemo } from "react"; -import { createPortal } from "react-dom"; import { useToggle } from "react-use"; import classNames from "classnames"; import { renderIcon, DotsIcon } from "@josemi-icons/react"; @@ -14,7 +13,6 @@ import { Logo } from "./Logo.tsx"; import { groupByDate, formatDate } from "../utils/dates.ts"; import * as styles from "../styles/components.css"; -import { fontSize2xl, fontFamilyHeading } from "folio-react/styles/utilities.css"; import type { JSX, SyntheticEvent } from "react"; @@ -236,12 +234,7 @@ export const Sidebar = (): JSX.Element => {
{!collapsed && ( - + )}
diff --git a/app/styles/components.css b/app/styles/components.css index 4184ac80..1a3fd86e 100644 --- a/app/styles/components.css +++ b/app/styles/components.css @@ -1,5 +1,12 @@ @import url("../../folio-react/styles/variables.css"); +/* home route component */ +.homeTitle { + composes: fontFamilyHeading from "./fonts.css"; + font-size: 4rem; + letter-spacing: -0.1rem; +} + /* main app layout */ .layout { background-color: var(--folio-color-background); @@ -38,6 +45,7 @@ /* logo component */ .logo { + composes: fontFamilyHeading from "./fonts.css"; letter-spacing: -0.1rem; line-height: 1; user-select: none; @@ -61,6 +69,9 @@ .sidebarExpanded { width: 16rem; } +.sidebarLogo { + font-size: var(--folio-fontSize-2xl); +} .sidebarHeader { align-items: center; display: flex; diff --git a/app/styles/fonts.css b/app/styles/fonts.css new file mode 100644 index 00000000..30db53fe --- /dev/null +++ b/app/styles/fonts.css @@ -0,0 +1,22 @@ +:root { + --folio-fontFamily-body: "Inter", sans-serif; + --folio-fontFamily-heading: "Fraunces", serif; +} + +/* font used in body */ +.fontFamilyBody { + font-family: var(--folio-fontFamily-body); + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; +} + +/* font used in headings */ +.fontFamilyHeading { + font-family: var(--folio-fontFamily-heading); + font-optical-sizing: auto; + font-weight: 800 !important; + font-style: normal; + font-variation-settings: "SOFT" 100, "WONK" 0; + /* letter-spacing: -0.2rem; */ +} From 32d66c1627949f00844b5959db8206b5d6b2c70b Mon Sep 17 00:00:00 2001 From: Josemi Date: Wed, 9 Sep 2026 20:58:23 +0200 Subject: [PATCH 36/58] Remove unused stuff in login component --- app/components/Login.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/components/Login.tsx b/app/components/Login.tsx index 0ecfe346..60e7709b 100644 --- a/app/components/Login.tsx +++ b/app/components/Login.tsx @@ -1,7 +1,5 @@ -import classNames from "classnames"; import { useState, useCallback, useRef } from "react"; import { useNavigate } from "react-router"; -import { ExclamationCircleIcon } from "@josemi-icons/react"; import { Button } from "folio-react/components/ui/button.tsx"; import { Centered } from "folio-react/components/ui/centered.tsx"; import { useToaster } from "../contexts/toaster.tsx"; @@ -85,15 +83,6 @@ export const Login = (): JSX.Element => { Your access token is printed in the terminal where you run the server. If you don't have it, please contact your administrator.
- {/* - {(configuration?.login_messages || []).map((message: any, index: number) => ( - - ))} - */}
-
-
+
folio v{process.env.VERSION}
From 36a8023a9cd38b31d732b8ae0bfc243ca332b94a Mon Sep 17 00:00:00 2001 From: Josemi Date: Tue, 22 Sep 2026 21:03:19 +0200 Subject: [PATCH 45/58] Fix application rules for development --- app/__stubs/rules.json | 59 ------------------------------------------ app/__stubs/storage.js | 14 +++++----- 2 files changed, 7 insertions(+), 66 deletions(-) delete mode 100644 app/__stubs/rules.json diff --git a/app/__stubs/rules.json b/app/__stubs/rules.json deleted file mode 100644 index 80ca5d8a..00000000 --- a/app/__stubs/rules.json +++ /dev/null @@ -1,59 +0,0 @@ -[ - { - "method": "GET", - "path": "/_api/status", - "headers": { - "Content-Type": "application/json" - }, - "response": { - "body": { - "data": { - "message": "ok" - } - } - } - }, - { - "method": "GET", - "path": "/_ai/quotas", - "headers": { - "Content-Type": "application/json" - }, - "response": { - "body": { - "data": { - "requestsLimit": 10, - "requestsUsed": 5 - } - } - } - }, - { - "method": "POST", - "path": "/_ai/generateElements", - "headers": { - "Content-Type": "application/json", - "X-Folio-Requests-Limit": 10, - "X-Folio-Requests-Used": 6 - }, - "response": { - "body": { - "data": { - "title": "Fake response", - "message": "Here is a clean version of your diagram.", - "elements": [ - { - "type": "shape", - "shape": "rectangle", - "x1": 0, - "y1": 0, - "x2": 100, - "y2": 100, - "fillColor": "#000" - } - ] - } - } - } - } -] diff --git a/app/__stubs/storage.js b/app/__stubs/storage.js index d2d8cc6c..34bfda41 100644 --- a/app/__stubs/storage.js +++ b/app/__stubs/storage.js @@ -23,11 +23,11 @@ export default [ body: { data: [ { - id: "board:board1", + id: "board1", metadata: { "name": "Board 1", - "created_at": "2025-07-25 00:00:00", - "updated_at": "2025-09-02 00:00:00", + "created_at": "2026-07-25 00:00:00", + "updated_at": "2026-09-10 00:00:00", }, }, ], @@ -36,19 +36,19 @@ export default [ }, { method: "GET", - path: "/_storage/board:board1", + path: "/_storage/board1", headers: { "Content-Type": "application/json" }, response: { body: { data: { - id: "board:board1", + id: "board1", value: {}, metadata: { "name": "Board 1", - "created_at": "2025-07-25 00:00:00", - "updated_at": "2025-09-02 00:00:00", + "created_at": "2026-07-25 00:00:00", + "updated_at": "2026-09-10 00:00:00", }, }, }, From 739d4c0c24f8dc950857daaa4210f20935767fb3 Mon Sep 17 00:00:00 2001 From: Josemi Date: Tue, 22 Sep 2026 21:03:32 +0200 Subject: [PATCH 46/58] Rename authentication context --- app/contexts/Authentication.tsx | 87 +++++++++++++++++++++++++++++++++ app/contexts/authentication.tsx | 57 --------------------- 2 files changed, 87 insertions(+), 57 deletions(-) create mode 100644 app/contexts/Authentication.tsx delete mode 100644 app/contexts/authentication.tsx diff --git a/app/contexts/Authentication.tsx b/app/contexts/Authentication.tsx new file mode 100644 index 00000000..1840bd09 --- /dev/null +++ b/app/contexts/Authentication.tsx @@ -0,0 +1,87 @@ +import { useState, useEffect, createContext, useContext, useMemo } from "react"; +import { Loading } from "folio-react/components/loading.jsx"; +import { useClients } from "./Clients.tsx"; +import { TOKEN_KEY } from "../constants.ts"; +import * as sharedStyles from "../styles/shared.css"; +import type { JSX, PropsWithChildren } from "react"; +import type { AuthenticatedUser } from "../types/clients.ts"; + +// authentication type +export type Authentication = { + user: AuthenticatedUser | null; + isAuthenticated: () => boolean; + refresh: () => void; +}; + +// authentication context +export const AuthenticationContext = createContext({} as Authentication); + +// hook to manage authentication +export const useAuthentication = (): Authentication => { + return useContext(AuthenticationContext); +}; + +// hook to access to authentication +export const useAutenticatedUser = (): any => { + return useContext(AuthenticationContext)?.user; +}; + +// main authentication provider +export const AuthenticationProvider = (props: PropsWithChildren): JSX.Element => { + const clients = useClients(); // get registered clients + const [authenticatedUser, setAuthenticatedUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // get current token from local storage + // when token changes, this will ensure that the authentication is processed + const token = window.localStorage.getItem(TOKEN_KEY); + + useEffect(() => { + // setAuthenticatedUser(null); + if (clients.authentication && !!token) { + setIsLoading(true); + clients.authentication?.getAuthenticatedUser() + .then((data: AuthenticatedUser) => { + setAuthenticatedUser(data); + }) + .catch((error: any) => { + console.error(error); + clients.authentication?.logout(); + setAuthenticatedUser(null); + }) + .finally(() => { + setIsLoading(false); + }); + } + else { + setIsLoading(false); + } + }, [clients.authentication, setAuthenticatedUser, setIsLoading, token]); + + // authentication manager + const authentication = useMemo(() => { + return { + user: authenticatedUser, + isAuthenticated: () => { + return !!authenticatedUser; + }, + refresh: () => { + setAuthenticatedUser(null); + }, + }; + }, [authenticatedUser, clients, isLoading]); + + // if we are currently getting user information, display a loading screen + // before authentication is finished + if (isLoading) { + return ( + + ); + } + + return ( + + {props.children} + + ); +}; diff --git a/app/contexts/authentication.tsx b/app/contexts/authentication.tsx deleted file mode 100644 index b05ed64f..00000000 --- a/app/contexts/authentication.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useState, useEffect, createContext, useContext } from "react"; -import { Outlet, useNavigate } from "react-router"; -import { Loading } from "folio-react/components/loading.jsx"; -import { useClients } from "./clients.tsx"; -import { TOKEN_KEY } from "../constants.ts"; -import { loadingWrapper } from "../index.css"; - -import type { JSX } from "react"; -import type { AuthenticatedUser } from "../types/clients.ts"; - -// authentication context -export const AuthenticationContext = createContext({} as AuthenticatedUser); - -// hook to access to authentication -export const useAutenticatedUser = (): any => { - return useContext(AuthenticationContext); -}; - -// main authentication provider -export const AuthenticationProvider = (): JSX.Element => { - const [ authenticatedUser, setAuthenticatedUser] = useState(null); - const clients = useClients(); - const navigate = useNavigate(); - - // get token from local storage - // this will ensure to validate the token when it changes - const token = window.localStorage.getItem(TOKEN_KEY); - - useEffect(() => { - setAuthenticatedUser(null); - if (clients.authentication) { - // TODO: check that the token exists and we can fetch the information about the authenticated user - clients.authentication?.getAuthenticatedUser() - .then((data: AuthenticatedUser) => { - setAuthenticatedUser(data); - }) - .catch((error: any) => { - console.error(error); - clients.authentication?.logout(); - navigate("/login"); - }); - } - }, [clients.authentication, navigate, setAuthenticatedUser, token]); - - // if service is defined and is not initialized, display a loading screen - if (!clients || !authenticatedUser) { - return ( - - ); - } - - return ( - - - - ); -}; From 1223b62c08ff1902d5e529eb3dcd33a74cb4a507 Mon Sep 17 00:00:00 2001 From: Josemi Date: Tue, 22 Sep 2026 21:04:13 +0200 Subject: [PATCH 47/58] Rename client context --- app/contexts/{clients.tsx => Clients.tsx} | 28 +++++++++++++---------- 1 file changed, 16 insertions(+), 12 deletions(-) rename app/contexts/{clients.tsx => Clients.tsx} (73%) diff --git a/app/contexts/clients.tsx b/app/contexts/Clients.tsx similarity index 73% rename from app/contexts/clients.tsx rename to app/contexts/Clients.tsx index 30a0301e..8121088f 100644 --- a/app/contexts/clients.tsx +++ b/app/contexts/Clients.tsx @@ -1,9 +1,11 @@ import { useState, useEffect, createContext, useContext } from "react"; import profile from "@profile"; +import { Loading } from "folio-react/components/loading.jsx"; import { createRemoteStorageClient, createLocalStorageClient } from "../clients/storage.ts"; import { createAuthenticationClient } from "../clients/authentication.ts"; +import * as sharedStyles from "../styles/shared.css"; import type { PropsWithChildren, JSX } from "react"; -import type { StorageClient, AuthenticationClient } from "../types/clients"; +import type { StorageClient, AuthenticationClient } from "../types/clients.ts"; export type Clients = { localStorage?: StorageClient | null; @@ -18,17 +20,17 @@ const createClientsFromProfile = async (): Promise => { remoteStorage: null, authentication: null, }; - // initialize local storage client - if (profile?.services?.localStorage) { - clients.localStorage = await createLocalStorageClient(profile.services.localStorage); + // initialize storage client + // note that at least a local or remote storage must be configured + if (profile?.localStorage) { + clients.localStorage = await createLocalStorageClient(profile.localStorage); } - // initialize remote storage client - if (profile?.services?.remoteStorage) { - clients.remoteStorage = await createRemoteStorageClient(profile.services.remoteStorage); + if (profile?.remoteStorage) { + clients.remoteStorage = await createRemoteStorageClient(profile.remoteStorage); } - // initialize authentication - if (profile?.services?.authentication) { - clients.authentication = await createAuthenticationClient(profile.services.authentication); + // initialize authentication client + if (profile?.authentication) { + clients.authentication = await createAuthenticationClient(profile.authentication); } return Promise.resolve(clients); }; @@ -44,16 +46,18 @@ export const useClients = (): Clients => { // main clients provider export const ClientsProvider = (props: PropsWithChildren): JSX.Element => { const [clients, setClients] = useState(null); + + // on mount, initialize clients useEffect(() => { createClientsFromProfile().then(initialClients => { setClients(initialClients); }); }, []); - // if service is defined and is not initialized, display a loading screen + // if clients are not initialized, display a loading screen if (!clients) { return ( -
Loading...
+ ); } From 3630d1b322f8a9c7011cad4512a8fed666327c94 Mon Sep 17 00:00:00 2001 From: Josemi Date: Tue, 22 Sep 2026 21:13:10 +0200 Subject: [PATCH 48/58] Initialize AppState context --- app/contexts/AppState.tsx | 184 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 app/contexts/AppState.tsx diff --git a/app/contexts/AppState.tsx b/app/contexts/AppState.tsx new file mode 100644 index 00000000..b311815d --- /dev/null +++ b/app/contexts/AppState.tsx @@ -0,0 +1,184 @@ +import { createContext, useContext, useEffect, useRef, useReducer, useCallback, useState } from "react"; +import { loadFromJson, saveAsJson } from "folio-react/lib/json.js"; +import { Loading } from "folio-react/components/loading.jsx"; +import { Collections } from "../constants.ts"; +import { useClients } from "./Clients.tsx"; +import * as sharedStyles from "../styles/shared.css"; +import type { JSX, PropsWithChildren } from "react"; +import type { Document } from "../types/clients.ts"; + +// application state type +export type AppState = { + version: number; + documents: Document[], + preferences: any; + + refresh: () => void; + + // create or import documents + createDocument: (data: any, metadata: any) => Promise; + importDocument: () => Promise; + + // manipulating a document + getDocument: (id: string) => Promise; + updateDocument: (id: string, data: any, metadata: any) => Promise; + deleteDocument: (id: string) => Promise; + + // additional document actions + downloadDocument: (id: string) => Promise; + duplicateDocument: (id: string) => Promise; + + // preferences api + getPreferences: () => Promise; + updatePreferences: (preferences: any) => Promise; +}; + +// internal app state context +const AppStateContext = createContext({} as AppState); + +// @description hook to use app state +export const useAppState = () => { + return useContext(AppStateContext); +}; + +// @description app state provider +export const AppStateProvider = (props: PropsWithChildren): JSX.Element => { + const [appVersion, incrementAppVersion] = useReducer((x: number): number => x + 1, 0); + const app = useRef({} as AppState); + const clients = useClients(); + + // get the storage client to use + // note that currently only one of them is enabled + const storageClient = clients.localStorage || clients.remoteStorage; + + // update app version in app.current.version + app.current.version = appVersion; + + // internal method to fetch the user preferences + const fetchUserPreferences = useCallback(() => { + // app.current.preferences = null; + storageClient?.get(Collections.PREFERENCES) + .then(data => { + app.current.preferences = data?.value || {}; + }) + .catch(error => { + console.error(error); + app.current.preferences = {}; + }) + .finally(() => { + incrementAppVersion(); + }); + }, [storageClient, incrementAppVersion]); + + // internal method to fetch documents of the logged user + const fetchUserDocuments = useCallback(() => { + app.current.documents = []; // clean documents reference + storageClient?.list().then(data => { + // Note: we have to exclude special documents + app.current.documents = (data || []).filter(documentItem => { + return documentItem.id !== Collections.PREFERENCES; + }); + incrementAppVersion(); + }); + }, [storageClient, incrementAppVersion]); + + // initialize app actions + if (!app.current.refresh) { + Object.assign(app.current, { + refresh: () => { + fetchUserDocuments(); + }, + createDocument: (data: any = {}, metadata: any = {}) => { + return storageClient?.create({ + value: data || {}, + metadata: Object.assign(metadata, { + created_at: Date.now(), + updated_at: Date.now(), + name: metadata?.title || data?.title || "Untitled", + }), + }); + }, + importDocument: () => { + return loadFromJson().then((data: any) => { + return app.current.createDocument(data, {}); + }); + }, + getDocument: (id: string) => { + return storageClient?.get(id); + }, + deleteDocument: async (id: string) => { + return storageClient?.delete(id); + }, + updateDocument: async (id: string, data: any, metadata: any) => { + return storageClient?.update(id, { + value: data || {}, + metadata: Object.assign(metadata, { + updated_at: Date.now(), + }), + }); + }, + downloadDocument: async (id: string) => { + const response = await app.current.getDocument(id); + if (response?.value) { + return saveAsJson(JSON.parse(response.value as any)); + } + }, + duplicateDocument: async (id: string) => { + const originalResponse: Document | null = await app.current.getDocument(id); + if (originalResponse) { + const newDocumentName = `Copy of ${originalResponse?.metadata?.name || "Untitled"}`; + return storageClient?.create({ + value: originalResponse?.value || {}, + metadata: Object.assign(originalResponse.metadata || {}, { + name: newDocumentName, + updated_at: Date.now(), + }), + }); + } + // document not found?? + return Promise.reject(new Error("Document not found")); + }, + getPreferences: () => { + return Promise.resolve(app.current.preferences || {}); + }, + updatePreferences: (preferences: any) => { + app.current.preferences = Object.assign({}, app.current.preferences, preferences); + return storageClient?.update(Collections.PREFERENCES, { + value: app.current.preferences, + metadata: { + updated_at: Date.now(), + }, + }); + }, + + // getUser: async () => { + // const response = await client.graphql(GET_USER, {}); + // return response?.getUser as User || null; + // }, + // logout: () => { + // // on logout, we just destroy the session. This will automatically + // // display the login screen + // client.logout(); + // }, + }); + } + + // when the app is mounted, fetch documents of the logged-in user + useEffect(() => { + fetchUserPreferences(); + fetchUserDocuments(); + }, []); + + // at least user preferences must be provided before continuing to the app + if (!app.current.preferences) { + return ( + + ); + } + + return ( + + {props.children} + + ); +}; From 03045686804bc28aaa6bbba5fe4b7f134d069622 Mon Sep 17 00:00:00 2001 From: Josemi Date: Tue, 22 Sep 2026 21:13:26 +0200 Subject: [PATCH 49/58] Update styles --- app/styles/components.css | 108 +++++++++++++++++++++++++++++++++----- app/styles/fonts.css | 22 -------- app/styles/root.css | 16 ++++++ app/styles/shared.css | 5 ++ 4 files changed, 117 insertions(+), 34 deletions(-) delete mode 100644 app/styles/fonts.css create mode 100644 app/styles/root.css create mode 100644 app/styles/shared.css diff --git a/app/styles/components.css b/app/styles/components.css index 1a3fd86e..89ec132d 100644 --- a/app/styles/components.css +++ b/app/styles/components.css @@ -1,14 +1,7 @@ @import url("../../folio-react/styles/variables.css"); -/* home route component */ -.homeTitle { - composes: fontFamilyHeading from "./fonts.css"; - font-size: 4rem; - letter-spacing: -0.1rem; -} - -/* main app layout */ -.layout { +/* board layout */ +.board { background-color: var(--folio-color-background); display: flex; flex-direction: row; @@ -20,13 +13,26 @@ top: 0; width: 100%; } -.layoutContent { +.boardContent { display: block; height: 100%; overflow: hidden; width: 100%; } +/* home route component */ +.homeTitle { + composes: fontFamilyHeading from "../../folio-react/styles/fonts.css"; + font-size: 4rem; + letter-spacing: -0.1rem; +} + +/* loading styles */ +.loading { + height: 100%; + width: 100%; +} + /* login styles */ .login { width: var(--folio-container-sm); @@ -45,7 +51,7 @@ /* logo component */ .logo { - composes: fontFamilyHeading from "./fonts.css"; + composes: fontFamilyHeading from "../../folio-react/styles/fonts.css"; letter-spacing: -0.1rem; line-height: 1; user-select: none; @@ -80,7 +86,7 @@ padding: 0.75rem; position: sticky; top: 0; - user-select: none;; + user-select: none; z-index: 50; } .sidebarBody { @@ -191,6 +197,11 @@ .sidebarDocumentButtonPopover { width: 12rem; } +.sidebarDocumentButtonDetails { + color: var(--folio-color-muted); + font-size: 0.625rem; + padding: 0.25rem 0.5rem; +} /* sidebar group */ .sidebarGroup { @@ -207,3 +218,76 @@ overflow: hidden; } +/* Welcome component styles */ +.welcomeOverlay { + z-index: 50; +} +.welcomeCentered { + height: 100%; + position: fixed; + z-index: 50; +} +.welcomeCard { + background-color: var(--folio-color-background); + border-radius: 1.5rem; + border: var(--folio-border) solid var(--folio-color-border); + box-shadow: var(--folio-shadow); + max-width: 32rem; + padding: 2rem; + width: 100%; +} +.welcomeHeader { + padding-top: 3rem; + padding-bottom: 2rem; + user-select: none; +} +.welcomeLogo { + composes: fontFamilyHeading from "../../folio-react/styles/fonts.css"; + margin-bottom: 1rem; + line-height: 1; + font-size: 4.5rem; + letter-spacing: -0.025em; +} +.welcomeFeatures { + display: flex; + gap: 0.5rem; + padding-bottom: 1.5rem; + width: 100%; +} +.welcomeFeatureCard { + background-color: var(--folio-color-accent); + border-radius: 0.375rem; + padding: 1rem; + width: 100%; +} +.welcomeFeatureIcon { + font-size: 1.875rem; + display: flex; + margin-bottom: 0.25rem; +} +.welcomeFeatureTitle { + font-size: 0.875rem; + font-weight: 700; +} +.welcomeFeatureDescription { + font-size: 0.625rem; +} +.welcomeActions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1.5rem; +} +.welcomeActionIcon { + display: flex; + align-items: center; + font-size: 1.125rem; +} +.welcomeFooter { + text-align: center; + font-size: 0.75rem; + color: var(--folio-color-muted); + user-select: none; +} + + diff --git a/app/styles/fonts.css b/app/styles/fonts.css deleted file mode 100644 index 30db53fe..00000000 --- a/app/styles/fonts.css +++ /dev/null @@ -1,22 +0,0 @@ -:root { - --folio-fontFamily-body: "Inter", sans-serif; - --folio-fontFamily-heading: "Fraunces", serif; -} - -/* font used in body */ -.fontFamilyBody { - font-family: var(--folio-fontFamily-body); - font-optical-sizing: auto; - font-weight: 400; - font-style: normal; -} - -/* font used in headings */ -.fontFamilyHeading { - font-family: var(--folio-fontFamily-heading); - font-optical-sizing: auto; - font-weight: 800 !important; - font-style: normal; - font-variation-settings: "SOFT" 100, "WONK" 0; - /* letter-spacing: -0.2rem; */ -} diff --git a/app/styles/root.css b/app/styles/root.css new file mode 100644 index 00000000..87d4c098 --- /dev/null +++ b/app/styles/root.css @@ -0,0 +1,16 @@ +@import url("../../folio-react/styles/reset.css"); +@import url("../../folio-react/styles/variables.css"); + +/* note: added .rootWrapper class but is deprecated, use .root instead */ +.root, .rootWrapper { + composes: fontFamilyBody from "../../folio-react/styles/fonts.css"; + position: fixed; + top: 0; + left: 0; + height: 100%; + width: 100%; + background-color: var(--folio-color-background); + color: var(--folio-color-foreground); + font-size: 16px; + display: flex; +} diff --git a/app/styles/shared.css b/app/styles/shared.css new file mode 100644 index 00000000..daed328b --- /dev/null +++ b/app/styles/shared.css @@ -0,0 +1,5 @@ +/* loading styles */ +.loading { + height: 100%; + width: 100%; +} From 68946c737c191f62f79dcc7df93ec1a5280eaf1d Mon Sep 17 00:00:00 2001 From: Josemi Date: Tue, 22 Sep 2026 21:16:59 +0200 Subject: [PATCH 50/58] Fix preferences endpoint --- app/__stubs/storage.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/__stubs/storage.js b/app/__stubs/storage.js index 34bfda41..b754022c 100644 --- a/app/__stubs/storage.js +++ b/app/__stubs/storage.js @@ -70,4 +70,14 @@ export default [ }, }, }, + { + method: "POST", + path: "/_storage/preferences", + headers: { + "Content-Type": "application/json" + }, + response: { + body: {}, + }, + }, ]; From 383c60fc4829117908999db3961175183d2bb8c6 Mon Sep 17 00:00:00 2001 From: Josemi Date: Tue, 22 Sep 2026 21:20:04 +0200 Subject: [PATCH 51/58] Fix mock storage endpoints --- app/__stubs/storage.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/__stubs/storage.js b/app/__stubs/storage.js index b754022c..909ceccb 100644 --- a/app/__stubs/storage.js +++ b/app/__stubs/storage.js @@ -54,6 +54,16 @@ export default [ }, }, }, + { + method: "PATCH", + path: "/_storage/board1", + headers: { + "Content-Type": "application/json" + }, + response: { + body: {}, + }, + }, { method: "GET", path: "/_storage/preferences", @@ -71,7 +81,7 @@ export default [ }, }, { - method: "POST", + method: "PATCH", path: "/_storage/preferences", headers: { "Content-Type": "application/json" From 986833d8bfdc948dd76a71f355e768f0d654aa60 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 10 Oct 2026 23:26:33 +0200 Subject: [PATCH 52/58] Move app components to root folder --- app/components/App.tsx | 97 ----- app/components/Editor.tsx | 137 ------- app/components/Home.tsx | 17 - app/components/Logo.tsx | 14 - app/components/Sidebar.tsx | 321 --------------- app/components/Welcome.tsx | 90 ----- components/Board.tsx | 107 +++++ .../Login.tsx => components/Login.old.tsx | 13 +- components/Login.tsx | 97 +++++ components/NotFound.tsx | 23 ++ components/Sidebar.tsx | 370 ++++++++++++++++++ components/Welcome.tsx | 97 +++++ 12 files changed, 700 insertions(+), 683 deletions(-) delete mode 100644 app/components/App.tsx delete mode 100644 app/components/Editor.tsx delete mode 100644 app/components/Home.tsx delete mode 100644 app/components/Logo.tsx delete mode 100644 app/components/Sidebar.tsx delete mode 100644 app/components/Welcome.tsx create mode 100644 components/Board.tsx rename app/components/Login.tsx => components/Login.old.tsx (89%) create mode 100644 components/Login.tsx create mode 100644 components/NotFound.tsx create mode 100644 components/Sidebar.tsx create mode 100644 components/Welcome.tsx diff --git a/app/components/App.tsx b/app/components/App.tsx deleted file mode 100644 index 54c93113..00000000 --- a/app/components/App.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useMemo } from "react"; -import { createHashRouter, RouterProvider, useParams, Outlet } from "react-router"; -import { useClients } from "../contexts/clients.tsx"; -import { Welcome } from "./Welcome.tsx"; -import { Home } from "./Home.tsx"; -import { Editor } from "./Editor.tsx"; -import { Sidebar } from "./Sidebar.tsx"; -import { Login } from "./Login.tsx"; -import { AuthenticationProvider } from "../contexts/authentication.tsx"; -import { AppStateProvider } from "../contexts/AppState.tsx"; -import { LocalStorageKeys, RemoteStorageKeys } from "../constants.ts"; -import * as styles from "../styles/components.css"; -import type { JSX } from "react"; -import type { StorageClient } from "../types/clients.ts"; - -// Board editor wrapper for remote storage: reads the board id from the URL -const RemoteEditor = (props: { storage: StorageClient; resource: string; }): JSX.Element => { - const { id } = useParams<{ id: string }>(); - return ( - - ); -}; - -export const App = (): JSX.Element => { - const clients = useClients(); - const router = useMemo(() => { - const routes = []; - if (clients.localStorage) { - routes.push({ - path: "/", - Component: Welcome, - }); - routes.push({ - path: "/board", - Component: () => ( - - ), - }); - } - // check if we have enabled the remote storage and the authentication - if (clients.authentication && clients.remoteStorage) { - routes.push({ - path: "/login", - Component: Login, - }); - // protected routes (require authentication) - routes.push({ - Component: AuthenticationProvider, - children: [ - { - Component: () => ( - -
- -
- -
-
-
- ), - children: [ - { - path: "/", - Component: Home, - }, - { - path: "/boards/:id", - Component: () => ( - - ), - }, - ] - }, - ], - }); - } - // create a browser router using routes generated from available clients - return createHashRouter(routes); - }, [clients]); - - return ( - - ); -}; diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx deleted file mode 100644 index 485a67f7..00000000 --- a/app/components/Editor.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useState, useRef, useMemo, useCallback, useEffect } from "react"; -// import { PREFERENCES } from "folio-react/constants.js"; -import { Editor as FolioEditor } from "folio-react/components/editor.tsx"; -import { Title } from "folio-react/components/title.tsx"; -// import { Library } from "folio-react/components/library.tsx"; -import { Preferences as PreferencesDialog, PreferencesContent } from "folio-react/components/preferences.tsx"; -import { Loading } from "folio-react/components/loading.jsx"; -import type { JSX } from "react"; -import type { Preferences } from "folio-react/contexts/preferences.tsx"; -import type { StorageClient } from "../types/clients.ts"; - -import * as styles from "../styles/components.css"; - -export type EditorProps = { - dataId: string; - preferencesId: string; - storage: StorageClient; -}; - -// internal method to change the document title -// const setDocumentTitle = (title: string) => { -// document.title = `${title} - folio lite`; -// }; - -export const Editor = ({ dataId, preferencesId, storage }: EditorProps): JSX.Element => { - const [ready, setReady] = useState(false); - const [preferences, setPreferences] = useState(null); - // const currentTitle = useRef("Untitled"); - - // we are using a reference to the initial preferences to prevent saving the same loaded preferences - // when the component is initialized - const initialPreferences = useRef(null); - const initialData = useRef(null); - - const componentsOverrides = useMemo(() => { - return { - Title: Title, - // Library: Library, - Preferences: () => ( - - { - setPreferences((prevPreferences: any) => { - return Object.assign({}, prevPreferences, newPreferences); - }); - }} - /> - - ), - }; - }, [setPreferences]); - - // default preferences for folio-lite app - const mergedPreferences = useMemo(() => { - return { - // [PREFERENCES.AI_ENABLED]: false, - ...preferences, - }; - }, [preferences]); - - // this is a wrapper around store.getInitialData to get and update the document title - const handleDataLoad = useCallback(() => { - return storage.get(dataId).then(data => { - // if (data?.value?.title) { - // setDocumentTitle(data.title); - // currentTitle.current = data.title; - // } - initialData.current = data; - return data?.value || {}; - }); - }, [dataId, storage]); - - // when the data in the editor changes, run store.updateData - const handleDataChange = useCallback((data: any) => { - // TODO: we have to include the metadata in the data submitted to the storage - storage.update(dataId, { - value: data, - metadata: { - ...(initialData?.current?.metadata || {}), - updated_at: Date.now(), - }, - }); - // update the document title if it has changed - // if (data?.title && data.title !== currentTitle.current) { - // setDocumentTitle(data.title); - // currentTitle.current = data.title; - // } - }, [dataId, storage]); - - // when the library in the editor changes, run store.updateLibrary - // const handleLibraryChange = useCallback((library: any) => { - // props.store.updateLibrary(library); - // }, [props.store]); - - // when the component is mounted, fetch preferences - useEffect(() => { - storage.get(preferencesId) - .then(data => { - initialPreferences.current = data || {}; - }) - .catch(error => { - console.error(error); - initialPreferences.current = {}; - }) - .finally(() => { - setPreferences(initialPreferences.current?.value || {}); - setReady(true); - }); - }, [preferencesId, storage]); - - // when preferences change, call useEffect to update the preferences storage - useEffect(() => { - if (preferences && preferences !== initialPreferences.current?.value) { - storage.update(preferencesId, { - value: preferences, - metadata: { - updated_at: Date.now(), - }, - }); - } - }, [storage, preferences]); - - if (!ready || !preferences) { - return ( - - ); - } - - return ( - - ); -}; diff --git a/app/components/Home.tsx b/app/components/Home.tsx deleted file mode 100644 index b2c74f3e..00000000 --- a/app/components/Home.tsx +++ /dev/null @@ -1,17 +0,0 @@ -// import classNames from "classnames"; -// import { Fragment } from "react"; -// import { PlusIcon } from "@josemi-icons/react"; -// import { Button } from "folio-react/components/ui/button.tsx"; -import { Centered } from "folio-react/components/ui/centered.tsx"; -// import { EmptyState } from "./EmptyState.tsx"; -// import { useClients } from "../contexts/clients.tsx"; -// import * as styles from "../styles/components.css"; - -import type { JSX } from "react"; - -export const Home = (): JSX.Element => { - // const { remoteStorage } = useClients(); - return ( - - ); -}; diff --git a/app/components/Logo.tsx b/app/components/Logo.tsx deleted file mode 100644 index b55b6655..00000000 --- a/app/components/Logo.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import classNames from "classnames"; -import { logo } from "../styles/components.css"; -import type { CSSProperties, JSX } from "react"; - -export type LogoProps = { - className?: string; - style?: CSSProperties; -}; - -export const Logo = (props: LogoProps): JSX.Element => ( -
- Folio -
-); diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx deleted file mode 100644 index 56063347..00000000 --- a/app/components/Sidebar.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import { useEffect, useCallback, useState, useRef, useMemo } from "react"; -import { useToggle } from "react-use"; -import classNames from "classnames"; -import { renderIcon, DotsIcon } from "@josemi-icons/react"; -import { Dropdown, DropdownPortalPosition } from "folio-react/components/ui/dropdown.tsx"; -// import { useDialog } from "folio-react/contexts/dialogs.tsx"; -// import { useConfirm } from "folio-react/contexts/confirm.jsx"; -import { useConfiguration } from "../contexts/configuration.tsx"; -import { useAppState } from "../contexts/AppState.tsx"; -import { useToaster } from "../contexts/toaster.tsx"; -import { Logo } from "./Logo.tsx"; -// import { RenameDocumentDialog } from "./dialogs/rename-document.tsx"; -import { groupByDate, formatDate } from "../utils/dates.ts"; - -import * as styles from "../styles/components.css"; - -import type { JSX, SyntheticEvent } from "react"; - -type ActionButtonProps = { - href?: string; - icon?: string; - text?: string; - collapsed?: boolean; - onClick: (event: SyntheticEvent) => void; -}; - -// @description action button component (create, import, settings, etc.) -const ActionButton = (props: ActionButtonProps): JSX.Element => ( - - {props.icon && ( -
- {renderIcon(props.icon)} -
- )} - {!props.collapsed && props.text && ( -
{props.text}
- )} -
-); - -type DocumentButtonProps = { - id: string; - name: string; - created_at: string; - updated_at: string; -}; - -const DocumentButton = (props: DocumentButtonProps): JSX.Element => { - // const { showDialog } = useDialog(); - // const { showConfirm } = useConfirm(); - const { app } = useAppState(); - const toaster = useToaster(); - - const title = props?.name || "Untitled"; - const active = false; // app.isBoardOpen(props?.id); - - // listener to handle renaming the document - // it will close the actions menu and call the onRename callback if provided - const handleRename = useCallback((event: SyntheticEvent) => { - event.preventDefault(); - // setActionsMenuOpen(false); - // showDialog({ - // component: ({ onClose }) => ( - // - // ), - // dialogClassName: "w-full max-w-sm", - // }); - }, [props.id]); - - // listener to save a local copy of the document - // it will close the actions menu and execute the save as action - const handleSaveAs = useCallback((event: SyntheticEvent) => { - event.preventDefault(); - // setActionsMenuOpen(false); - // app.downloadDocument(props.id) - // .then(() => { - // toaster.success("Document saved."); - // }) - // .catch(error => { - // console.error(error); - // toaster.error(error?.message || "An error occurred while saving the document."); - // }); - }, [props.id]); - - // listener to handle duplicating the document - const handleDuplicate = useCallback((event: SyntheticEvent) => { - event.preventDefault(); - // setActionsMenuOpen(false); - // app.duplicateDocument(props.id) - // .then((newDocument) => { - // app.refresh(); - // toaster.success(`Duplicate saved as '${newDocument?.name || "Untitled"}'.`); - // }) - // .catch(error => { - // console.error(error); - // toaster.error(error?.message || "An error occurred while duplicating the document."); - // }); - }, [props.id]); - - // listener to handle deletion of a document - // it will close the actions menu and call the onDelete callback if provided - const handleDelete = useCallback((event: SyntheticEvent) => { - event.preventDefault(); - // setActionsMenuOpen(false); - // showConfirm({ - // title: "Delete Document", - // message: `Are you sure you want to delete this document? This action cannot be undone.`, - // confirmText: "Yes, Delete", - // callback: () => { - // app.deleteDocument(props.id) - // .then(() => { - // // if the deleted board is the current one, redirect to the home page - // if (active) { - // app.openHome(); - // } - // app.refresh(); - // toaster.success("Document deleted successfully."); - // }) - // .catch(error => { - // console.error(error); - // toaster.error(error?.message || "An error occurred while deleting the document."); - // }); - // }, - // }); - }, [props.id, active, app]); - - const itemClass = classNames(styles.sidebarDocumentButton, { - [styles.sidebarDocumentButtonActive]: active, - }); - - return ( - -
-
- {renderIcon("file")} -
-
- {title} -
-
- ( -
- -
- )} - contentStyle={{ - position: "fixed", - zIndex: "50", - }} - contentRender={() => ( - - - - Rename - - - - Save a Copy - - - - Duplicate - - - - - Delete - - - {/* -
-
Created {formatDate(props.created_at)}.
-
Last edited {formatDate(props.updated_at)}.
-
- */} -
- )} - /> -
- ); -}; - -const Group = ({ title, items }: { title: string, items: any[] }): JSX.Element => ( -
-
-
{title || ""}
-
- {(items || []).map((item: any, index: number) => ( - - ))} -
-); - -// @description separator for the sidebar -// const Separator = (): React.JSX.Element => ( -//
-// ); - -// export the sidebar component -export const Sidebar = (): JSX.Element => { - const { app } = useAppState(); - const [collapsed, toggleCollapsed] = useToggle(false); - const websiteConfig = useConfiguration(); - const sidebarClass = classNames({ - [styles.sidebar]: true, - [styles.sidebarCollapsed]: collapsed, - [styles.sidebarExpanded]: !collapsed, - }); - - // note that this event will not be triggered if the sidebar is collapsed - const handleToggleCollapsed = useCallback(() => { - if (collapsed) { - toggleCollapsed(); - } - }, [collapsed, toggleCollapsed]); - - // group boards by the updated_at field - const groups = useMemo(() => { - return groupByDate(app.documents || [], "updated_at"); - }, [app.documents]); - - return ( -
-
-
- {!collapsed && ( - - )} -
-
- { - event.stopPropagation(); - toggleCollapsed(); - }} - icon={collapsed ? "sidebar-left-open" : "sidebar-left-close"} - /> -
-
-
-
- { - event.stopPropagation(); - // app.createDocument(Collection.BOARD, {}).then((board: any) => { - // app.openBoard(board.id); - // app.refresh(); - // }); - }} - collapsed={collapsed} - icon="plus" - text="Create Document" - /> - { - event.stopPropagation(); - // app.importDocument().then((board: any) => { - // app.openBoard(board.id); - // app.refresh(); - // }); - }} - collapsed={collapsed} - icon="upload" - text="Import Document" - /> -
- {!collapsed && groups.today.length > 0 && ( - - )} - {!collapsed && groups.yesterday.length > 0 && ( - - )} - {!collapsed && groups.thisWeek.length > 0 && ( - - )} - {!collapsed && groups.thisMonth.length > 0 && ( - - )} - {!collapsed && groups.others.length > 0 && ( - - )} -
-
- { - event.stopPropagation(); - // app.logout(); - }} - collapsed={collapsed} - icon="logout" - text="Sign out" - /> -
-
- ); -}; diff --git a/app/components/Welcome.tsx b/app/components/Welcome.tsx deleted file mode 100644 index 12a2d94a..00000000 --- a/app/components/Welcome.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Fragment, useCallback } from "react"; -import { useNavigate } from "react-router"; -import { renderIcon, DrawingIcon, FolderIcon } from "@josemi-icons/react"; -import { Button } from "folio-react/components/ui/button.tsx"; -import { Centered } from "folio-react/components/ui/centered.tsx"; -import { Overlay, OverlayVariant } from "folio-react/components/ui/overlay.tsx"; -import * as styles from "../styles/components.css"; -// import { loadFromJson } from "folio-react/lib/json.js"; -import type { JSX } from "react"; - -const defaultWelcomeFeatures = [ - { - icon: "shield-check", - title: "Privacy First.", - description: "Folio uses the local storage of your browser to ensure that your drawings are accessible only to you.", - }, - { - icon: "drawing", - title: "Boundless Canvas.", - description: "Unleash your imagination on an infinite canvas for sketching, prototyping, and creating.", - }, -]; - -export const Welcome = (): JSX.Element => { - const navigate = useNavigate(); - const handleLoad = useCallback(() => { - // loadFromJson() - // .then(data => { - // editor.fromJSON(data); - // editor.dispatchChange(); - // editor.update(); - // setWelcomeVisible(false); - // }) - // .catch(error => console.error(error)); - navigate("/board"); - }, [navigate]); - const handleStartDrawing = useCallback(() => { - navigate("/board"); - }, [navigate]); - - return ( - - - -
-
-
- Folio. -
-
- Welcome to folio, the minimal and infinite whiteboard for sketching and prototyping. -
-
-
- {defaultWelcomeFeatures.map(feature => ( -
-
- {renderIcon(feature.icon)} -
-
- {feature.title} -
-
- {feature.description} -
-
- ))} -
-
- - -
-
- folio v{process.env.VERSION} -
-
-
-
- ); -}; diff --git a/components/Board.tsx b/components/Board.tsx new file mode 100644 index 00000000..5dc7af47 --- /dev/null +++ b/components/Board.tsx @@ -0,0 +1,107 @@ +import { useState, useRef, useMemo, useCallback, useEffect } from "react"; +// import { PREFERENCES } from "folio-react/constants.js"; +import { Editor } from "folio-react/components/editor.tsx"; +import { Title } from "folio-react/components/title.tsx"; +// import { Library } from "folio-react/components/library.tsx"; +import { Preferences as PreferencesDialog, PreferencesContent } from "folio-react/components/preferences.tsx"; +import { NotFound } from "./NotFound.tsx"; +import type { JSX } from "react"; + +// internal method to change the document title +// const setDocumentTitle = (title: string) => { +// document.title = `${title} - folio lite`; +// }; + +export type BoardProps = { + readonly?: boolean; + preferences?: any; + onDataLoad?: () => Promise; + onDataChange?: (data: any) => void; + onPreferencesChange?: (preferences: any) => void; +}; + +export const Board = (props : BoardProps): JSX.Element => { + const [error, setError] = useState(null); + const [preferences, setPreferences] = useState(props.preferences || {}); + // const currentTitle = useRef("Untitled"); + // we are using a reference to the initial preferences to prevent saving the same loaded preferences + // when the component is initialized + const initialPreferences = useRef(props.preferences || {}); + + // generate components overrides + const componentsOverrides = useMemo(() => { + return { + Title: Title, + // Library: Library, + Preferences: () => ( + + { + setPreferences((prevPreferences: any) => { + return Object.assign({}, prevPreferences, newPreferences); + }); + }} + /> + + ), + }; + }, [setPreferences]); + + // merge user preferences with pre-defined preferences of the application + // const mergedPreferences = useMemo(() => { + // return { + // ...preferences, + // }; + // }, [preferences]); + + // this is a wrapper around store.getInitialData to get and update the document title + const handleDataLoad = useCallback(() => { + if (typeof props.onDataLoad === "function") { + return props.onDataLoad().catch((error: any) => { + setError(error); + }); + } + // if no data load function is provided, return an empty object + return Promise.resolve({}); + }, [props.onDataLoad, setError]); + + // when the data in the editor changes, run store.updateData + const handleDataChange = useCallback((data: any) => { + if (typeof props.onDataChange === "function") { + props.onDataChange(data); + } + // update the document title if it has changed + // if (data?.title && data.title !== currentTitle.current) { + // setDocumentTitle(data.title); + // currentTitle.current = data.title; + // } + }, [props.onDataChange]); + + // when the library in the editor changes, run store.updateLibrary + // const handleLibraryChange = useCallback((library: any) => { + // props.store.updateLibrary(library); + // }, [props.store]); + + // when preferences change, call useEffect to update the preferences storage + useEffect(() => { + if (typeof props.onPreferencesChange === "function") { + if (preferences && preferences !== initialPreferences.current) { + props.onPreferencesChange(preferences); + } + } + }, [props.onPreferencesChange, preferences]); + + if (error) { + return + } + + return ( + + ); +}; diff --git a/app/components/Login.tsx b/components/Login.old.tsx similarity index 89% rename from app/components/Login.tsx rename to components/Login.old.tsx index 60e7709b..6925aec9 100644 --- a/app/components/Login.tsx +++ b/components/Login.old.tsx @@ -2,9 +2,8 @@ import { useState, useCallback, useRef } from "react"; import { useNavigate } from "react-router"; import { Button } from "folio-react/components/ui/button.tsx"; import { Centered } from "folio-react/components/ui/centered.tsx"; -import { useToaster } from "../contexts/toaster.tsx"; -import { useClients } from "../contexts/clients.tsx"; -import { Logo } from "./Logo.tsx"; +import { useToaster } from "../contexts/Toaster.tsx"; +import { useClients } from "../contexts/Clients.tsx"; // import { useConfiguration } from "../contexts/configuration.tsx"; import * as formStyles from "folio-react/styles/form.css"; @@ -60,10 +59,10 @@ export const Login = (): JSX.Element => {
- + Welcome to Folio.
-
- You need to log in with your access token to continue. +
+ Sketch and design with freedom.
{
diff --git a/components/Login.tsx b/components/Login.tsx new file mode 100644 index 00000000..a5910afc --- /dev/null +++ b/components/Login.tsx @@ -0,0 +1,97 @@ +import { useRef, useState, useCallback } from "react"; +// import { ExclamationCircleIcon } from "@josemi-icons/react"; +import { Button } from "folio-react/components/ui/button.tsx"; +// import { useConfiguration } from "../contexts/configuration.tsx"; +import { useToaster } from "../contexts/Toaster.tsx"; +// import type { LoginMessage } from "../contexts/configuration.tsx"; +import type { JSX, KeyboardEvent } from "react"; +import type { Credentials } from "../lib/clients/types.ts"; + +// const LoginMessageAlert = ({ title, content }: LoginMessage): React.JSX.Element => { +// return ( +//
+//
+//
+// +//
+//
{title}
+//
+//
{content}
+//
+// ); +// }; + +export type LoginProps = { + onLogin: (credentials: Credentials) => Promise; +}; + +// @description login component +export const Login = (props: LoginProps): JSX.Element => { + const toaster = useToaster(); + // const websiteConfig = useConfiguration(); + const [loading, setLoading] = useState(false); + const accessTokenRef = useRef(null); + + const handleLogin = useCallback(() => { + const accessToken = (accessTokenRef.current?.value || "").trim(); + if (!accessToken) { + return toaster.error("The access token is required to log in."); + } + + // try to login with the provided access token + setLoading(true); + // client.login({ token: accessToken }) + // .catch((error: any) => { + // toaster.error(error?.message || "An error occurred while logging in."); + // }) + // .finally(() => setLoading(false)); + props.onLogin({ token: accessToken }).catch((error: any) => { + console.error(error); + toaster.error(error?.message || "An error occurred while logging in."); + setLoading(false); + }); + }, [setLoading, props.onLogin]); + + return ( +
+
+ folio. +
+
+ You need to log in with your access token to continue. +
+
+ { + if (event.key === "Enter") { + event.preventDefault(); + handleLogin(); + } + }} + /> +
+ Your access token is printed in the terminal where you run the server. If you don't have it, please contact your administrator. +
+
+ {/* + {(websiteConfig.login_messages || []).map((message: LoginMessage, index: number) => ( + + ))} + */} +
+ +
+
+ ); +}; diff --git a/components/NotFound.tsx b/components/NotFound.tsx new file mode 100644 index 00000000..751e3326 --- /dev/null +++ b/components/NotFound.tsx @@ -0,0 +1,23 @@ +import { Link } from "react-router"; +import { Centered } from "folio-react/components/ui/centered.tsx"; +import type { JSX } from "react"; + +// not found component +export const NotFound = (): JSX.Element => ( + +
+
+ Sorry, this page was not found. +
+
+ The page you are looking for does not exist or has been moved. + Please check the URL or return to the home page. +
+
+ + Go to Home + +
+
+
+); \ No newline at end of file diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx new file mode 100644 index 00000000..35b47295 --- /dev/null +++ b/components/Sidebar.tsx @@ -0,0 +1,370 @@ +import { useEffect, useCallback, useState, useRef, useMemo } from "react"; +import { Link } from "react-router"; +import { useToggle } from "react-use"; +import classNames from "classnames"; +import { renderIcon, DotsIcon } from "@josemi-icons/react"; +import { Dropdown, DropdownPortalPosition } from "folio-react/components/ui/dropdown.tsx"; +// import { useDialog } from "folio-react/contexts/dialogs.tsx"; +// import { useConfirm } from "folio-react/contexts/confirm.jsx"; +// import { useConfiguration } from "../contexts/configuration.tsx"; +import { useToaster } from "../contexts/Toaster.tsx"; +// import { RenameDocumentDialog } from "./dialogs/rename-document.tsx"; +import { groupByDate, formatDate } from "../lib/utils/dates.ts"; +import type { JSX, SyntheticEvent } from "react"; + +export type SidebarDocument = { + id: string; + name: string; + created_at: string; + updated_at: string; +}; + +type ActionButtonProps = { + href?: string; + icon?: string; + text?: string; + collapsed?: boolean; + onClick: (event: SyntheticEvent) => void; +}; + +// @description action button component (create, import, settings, etc.) +const ActionButton = (props: ActionButtonProps): JSX.Element => { + const actionClass = classNames({ + "h-8 px-2 py-1": true, + "flex items-center flex-nowrap gap-2 cursor-pointer rounded-md overflow-hidden": true, + "hover:bg-gray-200 text-gray-700 hover:text-gray-950": true, + }); + return ( + + {props.icon && ( +
+ {renderIcon(props.icon)} +
+ )} + {!props.collapsed && props.text && ( +
{props.text}
+ )} +
+ ); +}; + +type DocumentButtonProps = { + id: string; + name: string; + created_at: string; + updated_at: string; + onDownload: () => void; + onDuplicate: () => void; + onDelete: () => void; +}; + +const DocumentButton = (props: DocumentButtonProps): JSX.Element => { + // const { showDialog } = useDialog(); + // const { showConfirm } = useConfirm(); + const toaster = useToaster(); + + const title = props?.name || "Untitled"; + const active = false; // app.isBoardOpen(props?.id); + + // listener to handle renaming the document + // it will close the actions menu and call the onRename callback if provided + const handleRename = useCallback((event: SyntheticEvent) => { + event.preventDefault(); + // setActionsMenuOpen(false); + // showDialog({ + // component: ({ onClose }) => ( + // + // ), + // dialogClassName: "w-full max-w-sm", + // }); + }, [props.id]); + + // listener to save a local copy of the document + // it will close the actions menu and execute the save as action + const handleSaveAs = useCallback((event: SyntheticEvent) => { + event.preventDefault(); + // setActionsMenuOpen(false); + // app.downloadDocument(props.id) + // .then(() => { + // toaster.success("Document saved."); + // }) + // .catch(error => { + // console.error(error); + // toaster.error(error?.message || "An error occurred while saving the document."); + // }); + }, [props.id]); + + // listener to handle duplicating the document + const handleDuplicate = useCallback((event: SyntheticEvent) => { + event.preventDefault(); + // setActionsMenuOpen(false); + // app.duplicateDocument(props.id) + // .then((newDocument) => { + // app.refresh(); + // toaster.success(`Duplicate saved as '${newDocument?.name || "Untitled"}'.`); + // }) + // .catch(error => { + // console.error(error); + // toaster.error(error?.message || "An error occurred while duplicating the document."); + // }); + }, [props.id]); + + // listener to handle deletion of a document + // it will close the actions menu and call the onDelete callback if provided + const handleDelete = useCallback((event: SyntheticEvent) => { + event.preventDefault(); + // setActionsMenuOpen(false); + // showConfirm({ + // title: "Delete Document", + // message: `Are you sure you want to delete this document? This action cannot be undone.`, + // confirmText: "Yes, Delete", + // callback: () => { + // app.deleteDocument(props.id) + // .then(() => { + // // if the deleted board is the current one, redirect to the home page + // if (active) { + // app.openHome(); + // } + // app.refresh(); + // toaster.success("Document deleted successfully."); + // }) + // .catch(error => { + // console.error(error); + // toaster.error(error?.message || "An error occurred while deleting the document."); + // }); + // }, + // }); + }, [props.id, active]); + + const itemClass = classNames({ + "relative group rounded-md w-full flex items-center py-1 px-2": true, + "bg-gray-200 text-gray-900": active, + "hover:bg-gray-200 text-gray-600 hover:text-gray-900": !active, + }); + return ( + +
+
+ {renderIcon("file")} +
+
+ {title} +
+
+ ( +
+ +
+ )} + contentStyle={{ + position: "fixed", + zIndex: "50", + }} + contentRender={() => ( + + {/* + + + Rename + + */} + + + Save a Copy + + + + Duplicate + + + + + Delete + + +
+
Created {formatDate(props.created_at)}.
+
Last edited {formatDate(props.updated_at)}.
+
+
+ )} + /> + + ); +}; + +type GroupedDocumentsProps = { + title: string; + documents: SidebarDocument[], + onDocumentDownload: (id: string) => void; + onDocumentDuplicate: (id: string) => void; + onDocumentDelete: (id: string) => void; +}; + +const GroupedDocuments = (props: GroupedDocumentsProps): JSX.Element => ( +
+
+
{props.title}
+
+ {(props.documents || []).map((item: any, index: number) => ( + props.onDocumentDownload(item.id)} + onDuplicate={() => props.onDocumentDuplicate(item.id)} + onDelete={() => props.onDocumentDelete(item.id)} + /> + ))} +
+); + +// @description separator for the sidebar +const Separator = (): JSX.Element => ( +
+); + +export type SidebarProps = { + documents: SidebarDocument[]; + defaultCollapsed: boolean; + onDocumentDownload: (id: string) => void; + onDocumentDuplicate: (id: string) => void; + onDocumentDelete: (id: string) => void; +}; + +// export the sidebar component +export const Sidebar = (props: SidebarProps): JSX.Element => { + const [collapsed, toggleCollapsed] = useToggle(props.defaultCollapsed); + const sidebarClass = classNames({ + "h-full shrink-0 flex flex-col justify-between": true, + "w-16 cursor-e-resize": collapsed, + "w-64": !collapsed, + }); + + // note that this event will not be triggered if the sidebar is collapsed + const handleToggleCollapsed = useCallback(() => { + if (collapsed) { + toggleCollapsed(); + } + }, [collapsed, toggleCollapsed]); + + // group boards by the updated_at field + const groups = useMemo(() => { + return groupByDate(props.documents || [], "updated_at"); + }, [props.documents]); + + return ( +
+
+
+ {/* +
+ {!collapsed && ( +
{websiteConfig.sidebar_title || websiteConfig.title || "folio."}
+ )} +
+ */} +
+ { + event.stopPropagation(); + toggleCollapsed(); + }} + icon={collapsed ? "sidebar-left-open" : "sidebar-left-close"} + /> +
+
+
+
+ { + event.stopPropagation(); + // app.createDocument(Collection.BOARD, {}).then((board: any) => { + // app.openBoard(board.id); + // app.refresh(); + // }); + }} + collapsed={collapsed} + icon="plus" + text="Create Document" + /> + { + event.stopPropagation(); + // app.importDocument().then((board: any) => { + // app.openBoard(board.id); + // app.refresh(); + // }); + }} + collapsed={collapsed} + icon="upload" + text="Import Document" + /> +
+ {!collapsed && groups.today.length > 0 && ( + + )} + {!collapsed && groups.yesterday.length > 0 && ( + + )} + {!collapsed && groups.thisWeek.length > 0 && ( + + )} + {!collapsed && groups.thisMonth.length > 0 && ( + + )} + {!collapsed && groups.others.length > 0 && ( + + )} +
+
+
+ { + event.stopPropagation(); + // app.logout(); + }} + collapsed={collapsed} + icon="logout" + text="Sign out" + /> +
+
+ ); +}; diff --git a/components/Welcome.tsx b/components/Welcome.tsx new file mode 100644 index 00000000..be5d0f0b --- /dev/null +++ b/components/Welcome.tsx @@ -0,0 +1,97 @@ +import { useCallback, useState } from "react"; +import { renderIcon, DrawingIcon, FolderIcon, XIcon } from "@josemi-icons/react"; +import { Button } from "folio-react/components/ui/button.tsx"; +import { loadFromJson } from "folio-react/lib/json.js"; +import type { JSX } from "react"; + +const defaultWelcomeFeatures = [ + { + icon: "shield-check", + title: "Privacy First.", + description: "Folio uses the local storage of your browser to ensure that your drawings are accessible only to you.", + }, + { + icon: "drawing", + title: "Boundless Canvas.", + description: "Unleash your imagination on an infinite canvas for sketching, prototyping, and creating.", + }, +]; + +export type WelcomeProps = { + onClose?: () => void; +}; + +export const Welcome = (props: WelcomeProps): JSX.Element => { + // const editor = useEditor(); + const [welcomeVisible, setWelcomeVisible] = useState(() => { + // return editor.pages?.length === 1 && editor.page?.elements?.length === 0; + return true; + }); + + // handle load board from file + const handleLoad = useCallback(() => { + // loadFromJson() + // .then(data => { + // editor.fromJSON(data); + // editor.dispatchChange(); + // editor.update(); + // setWelcomeVisible(false); + // }) + // .catch(error => console.error(error)); + }, [setWelcomeVisible]); + + // handle start drawing + const handleStartDrawing = useCallback(() => { + setWelcomeVisible(false); + }, [setWelcomeVisible]); + + return ( +
+
+
+ +
+
+
+
+ folio. +
+
+ Welcome to folio, the minimal and infinite whiteboard for sketching and prototyping. +
+
+
+ {defaultWelcomeFeatures.map(feature => ( +
+
+ {renderIcon(feature.icon)} +
+
+ {feature.title} +
+
+ {feature.description} +
+
+ ))} +
+
+ + +
+
+ folio v{process.env.VERSION} +
+
+ ); +}; From c1a0598b037266616f42d493c518960c77f637e1 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 10 Oct 2026 23:27:19 +0200 Subject: [PATCH 53/58] Move --- app/profiles/development.ts | 22 --------- app/types/clients.ts | 24 --------- {app/lib => lib}/api.ts | 0 {app => lib}/clients/authentication.ts | 8 +-- {app => lib}/clients/storage.ts | 10 ++-- lib/clients/types.ts | 28 +++++++++++ lib/utils/dates.ts | 60 +++++++++++++++++++++++ profiles/development.ts | 23 +++++++++ {app/profiles => profiles}/lite.ts | 0 app/types/profile.ts => profiles/types.ts | 8 ++- 10 files changed, 123 insertions(+), 60 deletions(-) delete mode 100644 app/profiles/development.ts delete mode 100644 app/types/clients.ts rename {app/lib => lib}/api.ts (100%) rename {app => lib}/clients/authentication.ts (83%) rename {app => lib}/clients/storage.ts (87%) create mode 100644 lib/clients/types.ts create mode 100644 lib/utils/dates.ts create mode 100644 profiles/development.ts rename {app/profiles => profiles}/lite.ts (100%) rename app/types/profile.ts => profiles/types.ts (71%) diff --git a/app/profiles/development.ts b/app/profiles/development.ts deleted file mode 100644 index 76e48c1d..00000000 --- a/app/profiles/development.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as idb from "idb-keyval"; -import type { Profile } from "../types/profile.ts"; -import type { UseStore } from "idb-keyval"; - -export default { - name: "Folio Development", - config: () => { - return Promise.resolve({}); - }, - services: { - // localStorage: { - // dbName: "folio", - // storeName: "folio-store", - // initialize: async (store: UseStore) => { - // // TODO: initialize storage - // }, - // }, - authentication: { - host: "/_auth", - }, - }, -} as Profile; diff --git a/app/types/clients.ts b/app/types/clients.ts deleted file mode 100644 index 0908ebf8..00000000 --- a/app/types/clients.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type Credentials = { - token?: string; -}; - -export type Document = { - name?: string; - id?: string; - value?: object; - metadata?: object; -}; - -export interface StorageClient { - list(): Promise; - get(id: string): Promise; - create(data: Document): Promise; - update(id: string, data: Partial): Promise; - delete(id: string): Promise; -}; - -export interface AuthenticationClient { - login(credentials: Credentials): Promise; - logout(): Promise; - isAuthenticated(): Promise; -}; diff --git a/app/lib/api.ts b/lib/api.ts similarity index 100% rename from app/lib/api.ts rename to lib/api.ts diff --git a/app/clients/authentication.ts b/lib/clients/authentication.ts similarity index 83% rename from app/clients/authentication.ts rename to lib/clients/authentication.ts index 6f33a89c..c2fd874c 100644 --- a/app/clients/authentication.ts +++ b/lib/clients/authentication.ts @@ -1,7 +1,7 @@ -import { createApiClient } from "../lib/api.ts"; -import { TOKEN_KEY } from "../constants.ts"; -import type { AuthenticationClient, Credentials } from "../types/clients.ts"; -import type { AuthenticationConfig } from "../types/profile.ts"; +import { createApiClient } from "../api.ts"; +import { TOKEN_KEY } from "../../constants.ts"; +import type { AuthenticationClient, Credentials } from "./types.ts"; +import type { AuthenticationConfig } from "../../profiles/types.ts"; export const createAuthenticationClient = (config: AuthenticationConfig) => { const api = createApiClient(config.host); diff --git a/app/clients/storage.ts b/lib/clients/storage.ts similarity index 87% rename from app/clients/storage.ts rename to lib/clients/storage.ts index ebffcd7e..9041f222 100644 --- a/app/clients/storage.ts +++ b/lib/clients/storage.ts @@ -1,8 +1,8 @@ import * as idb from "idb-keyval"; import type { UseStore } from "idb-keyval"; -import { createApiClient } from "../lib/api.ts"; -import type { StorageClient, Document } from "../types/clients.ts"; -import type { LocalStorageConfig, RemoteStorageConfig } from "../types/profile.ts"; +import { createApiClient } from "../api.ts"; +import type { StorageClient, StorageDocument } from "./types.ts"; +import type { LocalStorageConfig, RemoteStorageConfig } from "../../profiles/types.ts"; export const createLocalStorageClient = async (options: LocalStorageConfig): Promise => { // 1. create the store @@ -21,10 +21,10 @@ export const createLocalStorageClient = async (options: LocalStorageConfig): Pro metadata: {}, }); }, - create: (data: Document) => { + create: (data: StorageDocument) => { return idb.set(data.id as string, data.value, store); }, - update: (id: string, data: Partial) => { + update: (id: string, data: Partial) => { return idb.update(id, (prevValue) => ({ ...prevValue, ...data.value }), store); }, delete: (id: string) => { diff --git a/lib/clients/types.ts b/lib/clients/types.ts new file mode 100644 index 00000000..e4ad3fa7 --- /dev/null +++ b/lib/clients/types.ts @@ -0,0 +1,28 @@ +export type Credentials = { + token?: string; +}; + +export type StorageDocument = { + name?: string; + id?: string; + value?: any; + metadata?: any; +}; + +export type AuthenticatedUser = { + username: string; +}; + +export interface StorageClient { + list(): Promise; + get(id: string): Promise; + create(data: StorageDocument): Promise; + update(id: string, data: Partial): Promise; + delete(id: string): Promise; +}; + +export interface AuthenticationClient { + login(credentials: Credentials): Promise; + logout(): Promise; + getAuthenticatedUser(): Promise; +}; diff --git a/lib/utils/dates.ts b/lib/utils/dates.ts new file mode 100644 index 00000000..12cc9d8f --- /dev/null +++ b/lib/utils/dates.ts @@ -0,0 +1,60 @@ +export type GroupedDates = { + today: any[]; + yesterday: any[]; + thisWeek: any[]; + thisMonth: any[]; + others: any[]; +}; + +// @description Utility function to group items by a field based on their modification date or +// in another date field. +// @param {Array} items - The array of items to group. +// @param {String} field - The field to use for grouping, defaults to "updated_at". +// @returns {Object} An object with keys for Today, Yesterday, ThisWeek, ThisMonth, and Others, +export const groupByDate = (items: any[] = [], field: string = "updated_at"): GroupedDates => { + const now = new Date(); + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(startOfDay); + yesterday.setDate(yesterday.getDate() - 1); + const startOfWeek = new Date(startOfDay); + startOfWeek.setDate(startOfDay.getDate() - startOfDay.getDay()); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const groups: GroupedDates = { + today: [], + yesterday: [], + thisWeek: [], + thisMonth: [], + others: [] + }; + + items.forEach(item => { + const itemDate = new Date(item?.metadata?.[field]); + if (itemDate >= startOfDay) { + groups.today.push(item); + } else if (itemDate >= yesterday) { + groups.yesterday.push(item); + } else if (itemDate >= startOfWeek) { + groups.thisWeek.push(item); + } else if (itemDate >= startOfMonth) { + groups.thisMonth.push(item); + } else { + groups.others.push(item); + } + }); + + return groups; +}; + +// @description format a date to a human-readable string with hour and minutes +// @param {String} dateStr - The date string to format. +// @returns {String} The formatted date string with time. +export const formatDate = (dateStr: string): string => { + return new Date(dateStr).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +}; diff --git a/profiles/development.ts b/profiles/development.ts new file mode 100644 index 00000000..1c366d9b --- /dev/null +++ b/profiles/development.ts @@ -0,0 +1,23 @@ +import * as idb from "idb-keyval"; +import type { Profile } from "./types.ts"; +import type { UseStore } from "idb-keyval"; + +export default { + name: "Folio Development", + config: () => { + return Promise.resolve({}); + }, + // localStorage: { + // dbName: "folio", + // storeName: "folio-store", + // initialize: async (store: UseStore) => { + // // TODO: initialize storage + // }, + // }, + authentication: { + host: "/_auth", + }, + remoteStorage: { + host: "/_storage", + }, +} as Profile; diff --git a/app/profiles/lite.ts b/profiles/lite.ts similarity index 100% rename from app/profiles/lite.ts rename to profiles/lite.ts diff --git a/app/types/profile.ts b/profiles/types.ts similarity index 71% rename from app/types/profile.ts rename to profiles/types.ts index 6efdbd67..4f5ce149 100644 --- a/app/types/profile.ts +++ b/profiles/types.ts @@ -17,9 +17,7 @@ export type RemoteStorageConfig = { export type Profile = { name?: string; config?: object | (() => Promise); - services?: { - localStorage?: LocalStorageConfig; - remoteStorage?: RemoteStorageConfig; - authentication?: AuthenticationConfig; - } + localStorage?: LocalStorageConfig; + remoteStorage?: RemoteStorageConfig; + authentication?: AuthenticationConfig; }; From 7e836def70dabb912a60d86eb67a3d8b7adc40fe Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 10 Oct 2026 23:28:08 +0200 Subject: [PATCH 54/58] Move all apop stuff to root folder --- {app => scripts}/__stubs/authentication.js | 0 {app => scripts}/__stubs/storage.js | 0 app/template.html => template.html | 12 +- app/tsconfig.json => tsconfig.json | 6 +- webpack.config.js | 123 +++++++++++++++++---- 5 files changed, 107 insertions(+), 34 deletions(-) rename {app => scripts}/__stubs/authentication.js (100%) rename {app => scripts}/__stubs/storage.js (100%) rename app/template.html => template.html (75%) rename app/tsconfig.json => tsconfig.json (74%) diff --git a/app/__stubs/authentication.js b/scripts/__stubs/authentication.js similarity index 100% rename from app/__stubs/authentication.js rename to scripts/__stubs/authentication.js diff --git a/app/__stubs/storage.js b/scripts/__stubs/storage.js similarity index 100% rename from app/__stubs/storage.js rename to scripts/__stubs/storage.js diff --git a/app/template.html b/template.html similarity index 75% rename from app/template.html rename to template.html index 41be93cf..f11e2366 100644 --- a/app/template.html +++ b/template.html @@ -4,17 +4,15 @@ - - - - + + Folio diff --git a/app/tsconfig.json b/tsconfig.json similarity index 74% rename from app/tsconfig.json rename to tsconfig.json index 06f0cf89..95e75603 100644 --- a/app/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.base.json", + "extends": "./tsconfig.base.json", "compilerOptions": { "allowJs": true, "allowImportingTsExtensions": true, @@ -11,8 +11,8 @@ "./profiles/development.ts" ], "folio-react": [ - "../folio-react/index.jsx", - "../folio-react/*" + "./folio-react/index.jsx", + "./folio-react/*" ], } } diff --git a/webpack.config.js b/webpack.config.js index 63d491f3..20de96f1 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,25 +1,44 @@ -import path from "node:path"; +import { resolve } from "node:path"; import webpack from "webpack"; import TerserPlugin from "terser-webpack-plugin"; +import CopyWebpackPlugin from "copy-webpack-plugin"; +import HtmlWebpackPlugin from "html-webpack-plugin"; +import { createServer } from "./scripts/webpack-server.js"; +import authenticationRules from "./scripts/__stubs/authentication.js"; +import storageRules from "./scripts/__stubs/storage.js"; import pkg from "./package.json" with {type: "json"}; export default { mode: process.env.NODE_ENV || "development", target: "web", - entry: { - "folio-react": { - import: path.resolve("folio-react/index.jsx"), - filename: "folio-react/folio.js", - layer: "folio-react", - }, - }, + entry: resolve("app.tsx"), + // entry: { + // "folio-react": { + // import: path.resolve("folio-react/index.jsx"), + // filename: "folio-react/folio.js", + // layer: "folio-react", + // }, + // }, output: { - path: path.resolve("."), + path: resolve("www"), publicPath: "./", - clean: false, - library: { - type: "module", + filename: "[name].[contenthash].js", + chunkFilename: "[name].[contenthash].chunk.js", + }, + // output: { + // path: path.resolve("."), + // publicPath: "./", + // clean: false, + // library: { + // type: "module", + // }, + // }, + resolve: { + alias: { + "folio-react": resolve("./folio-react/"), + "@profile": resolve("./profiles/development.ts"), }, + extensions: [".tsx", ".ts", ".js", ".jsx"], }, optimization: { minimize: true, @@ -28,26 +47,51 @@ export default { extractComments: false, }), ], + splitChunks: { + chunks: "all", + }, }, - experiments: { - layers: true, - outputModule: true, - }, - externals: { - byLayer: { - "folio-react": { - "react": "react", - "react-dom": "react-dom", - "react-dom/server": "react-dom/server", + // experiments: { + // layers: true, + // outputModule: true, + // }, + // externals: { + // byLayer: { + // "folio-react": { + // "react": "react", + // "react-dom": "react-dom", + // "react-dom/server": "react-dom/server", + // }, + // }, + // }, + devServer: { + hot: false, + static: { + directory: resolve("www"), + staticOptions: { + extensions: ["html"], }, }, + historyApiFallback: { + rewrites: [ + { from: /^\/$/, to: "index.html" }, + ], + }, + setupMiddlewares: createServer([ + ...authenticationRules, + ...storageRules, + ]), + devMiddleware: { + writeToDisk: true, + }, }, module: { rules: [ { test: /\.(j|t)sx?$/, include: [ - path.resolve("folio-react"), + resolve("."), + resolve("./folio-react"), ], exclude: /(node_modules|www)/, loader: "babel-loader", @@ -64,7 +108,7 @@ export default { ], plugins: [ "@babel/plugin-transform-runtime", - path.resolve("scripts/babel-plugin-obfuscate-classnames.js"), + // path.resolve("scripts/babel-plugin-obfuscate-classnames.js"), ], }, }, @@ -72,6 +116,20 @@ export default { test: /\.(png|jpg|jpeg|svg)$/, type: "asset/inline", }, + // { + // test: /\.css$/, + // use: [ + // "style-loader", + // { + // loader: "css-loader", + // options: { + // modules: { + // localIdentName: "[name]__[local]___[hash:base64:5]", + // }, + // }, + // }, + // ], + // }, ], }, plugins: [ @@ -82,5 +140,22 @@ export default { "process.env.URL_ISSUES": JSON.stringify(pkg.bugs), "process.env.URL_HOMEPAGE": JSON.stringify(pkg.homepage), }), + new CopyWebpackPlugin({ + patterns: [ + { + from: resolve("./node_modules/lowcss/low.css"), + to: "low.css", + }, + { + from: resolve("./resources/favicon-32x32.png"), + to: "favicon.png", + }, + ], + }), + new HtmlWebpackPlugin({ + template: resolve("template.html"), + filename: "index.html", + minify: true, + }), ], }; From 4e2a861e1b87683d879ef1d31db62f5e3bbb8d0a Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 10 Oct 2026 23:28:38 +0200 Subject: [PATCH 55/58] Move contexts to root folder --- app/contexts/AppState.tsx | 184 ------------------ app/contexts/Authentication.tsx | 87 --------- app/contexts/Toaster.tsx | 3 - app/contexts/configuration.tsx | 55 ------ contexts/AppState.tsx | 252 +++++++++++++++++++++++++ contexts/Authentication.tsx | 102 ++++++++++ {app/contexts => contexts}/Clients.tsx | 9 +- contexts/Navigation.tsx | 98 ++++++++++ contexts/Storage.tsx | 185 ++++++++++++++++++ contexts/Toaster.tsx | 111 +++++++++++ 10 files changed, 752 insertions(+), 334 deletions(-) delete mode 100644 app/contexts/AppState.tsx delete mode 100644 app/contexts/Authentication.tsx delete mode 100644 app/contexts/Toaster.tsx delete mode 100644 app/contexts/configuration.tsx create mode 100644 contexts/AppState.tsx create mode 100644 contexts/Authentication.tsx rename {app/contexts => contexts}/Clients.tsx (87%) create mode 100644 contexts/Navigation.tsx create mode 100644 contexts/Storage.tsx create mode 100644 contexts/Toaster.tsx diff --git a/app/contexts/AppState.tsx b/app/contexts/AppState.tsx deleted file mode 100644 index b311815d..00000000 --- a/app/contexts/AppState.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { createContext, useContext, useEffect, useRef, useReducer, useCallback, useState } from "react"; -import { loadFromJson, saveAsJson } from "folio-react/lib/json.js"; -import { Loading } from "folio-react/components/loading.jsx"; -import { Collections } from "../constants.ts"; -import { useClients } from "./Clients.tsx"; -import * as sharedStyles from "../styles/shared.css"; -import type { JSX, PropsWithChildren } from "react"; -import type { Document } from "../types/clients.ts"; - -// application state type -export type AppState = { - version: number; - documents: Document[], - preferences: any; - - refresh: () => void; - - // create or import documents - createDocument: (data: any, metadata: any) => Promise; - importDocument: () => Promise; - - // manipulating a document - getDocument: (id: string) => Promise; - updateDocument: (id: string, data: any, metadata: any) => Promise; - deleteDocument: (id: string) => Promise; - - // additional document actions - downloadDocument: (id: string) => Promise; - duplicateDocument: (id: string) => Promise; - - // preferences api - getPreferences: () => Promise; - updatePreferences: (preferences: any) => Promise; -}; - -// internal app state context -const AppStateContext = createContext({} as AppState); - -// @description hook to use app state -export const useAppState = () => { - return useContext(AppStateContext); -}; - -// @description app state provider -export const AppStateProvider = (props: PropsWithChildren): JSX.Element => { - const [appVersion, incrementAppVersion] = useReducer((x: number): number => x + 1, 0); - const app = useRef({} as AppState); - const clients = useClients(); - - // get the storage client to use - // note that currently only one of them is enabled - const storageClient = clients.localStorage || clients.remoteStorage; - - // update app version in app.current.version - app.current.version = appVersion; - - // internal method to fetch the user preferences - const fetchUserPreferences = useCallback(() => { - // app.current.preferences = null; - storageClient?.get(Collections.PREFERENCES) - .then(data => { - app.current.preferences = data?.value || {}; - }) - .catch(error => { - console.error(error); - app.current.preferences = {}; - }) - .finally(() => { - incrementAppVersion(); - }); - }, [storageClient, incrementAppVersion]); - - // internal method to fetch documents of the logged user - const fetchUserDocuments = useCallback(() => { - app.current.documents = []; // clean documents reference - storageClient?.list().then(data => { - // Note: we have to exclude special documents - app.current.documents = (data || []).filter(documentItem => { - return documentItem.id !== Collections.PREFERENCES; - }); - incrementAppVersion(); - }); - }, [storageClient, incrementAppVersion]); - - // initialize app actions - if (!app.current.refresh) { - Object.assign(app.current, { - refresh: () => { - fetchUserDocuments(); - }, - createDocument: (data: any = {}, metadata: any = {}) => { - return storageClient?.create({ - value: data || {}, - metadata: Object.assign(metadata, { - created_at: Date.now(), - updated_at: Date.now(), - name: metadata?.title || data?.title || "Untitled", - }), - }); - }, - importDocument: () => { - return loadFromJson().then((data: any) => { - return app.current.createDocument(data, {}); - }); - }, - getDocument: (id: string) => { - return storageClient?.get(id); - }, - deleteDocument: async (id: string) => { - return storageClient?.delete(id); - }, - updateDocument: async (id: string, data: any, metadata: any) => { - return storageClient?.update(id, { - value: data || {}, - metadata: Object.assign(metadata, { - updated_at: Date.now(), - }), - }); - }, - downloadDocument: async (id: string) => { - const response = await app.current.getDocument(id); - if (response?.value) { - return saveAsJson(JSON.parse(response.value as any)); - } - }, - duplicateDocument: async (id: string) => { - const originalResponse: Document | null = await app.current.getDocument(id); - if (originalResponse) { - const newDocumentName = `Copy of ${originalResponse?.metadata?.name || "Untitled"}`; - return storageClient?.create({ - value: originalResponse?.value || {}, - metadata: Object.assign(originalResponse.metadata || {}, { - name: newDocumentName, - updated_at: Date.now(), - }), - }); - } - // document not found?? - return Promise.reject(new Error("Document not found")); - }, - getPreferences: () => { - return Promise.resolve(app.current.preferences || {}); - }, - updatePreferences: (preferences: any) => { - app.current.preferences = Object.assign({}, app.current.preferences, preferences); - return storageClient?.update(Collections.PREFERENCES, { - value: app.current.preferences, - metadata: { - updated_at: Date.now(), - }, - }); - }, - - // getUser: async () => { - // const response = await client.graphql(GET_USER, {}); - // return response?.getUser as User || null; - // }, - // logout: () => { - // // on logout, we just destroy the session. This will automatically - // // display the login screen - // client.logout(); - // }, - }); - } - - // when the app is mounted, fetch documents of the logged-in user - useEffect(() => { - fetchUserPreferences(); - fetchUserDocuments(); - }, []); - - // at least user preferences must be provided before continuing to the app - if (!app.current.preferences) { - return ( - - ); - } - - return ( - - {props.children} - - ); -}; diff --git a/app/contexts/Authentication.tsx b/app/contexts/Authentication.tsx deleted file mode 100644 index 1840bd09..00000000 --- a/app/contexts/Authentication.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useState, useEffect, createContext, useContext, useMemo } from "react"; -import { Loading } from "folio-react/components/loading.jsx"; -import { useClients } from "./Clients.tsx"; -import { TOKEN_KEY } from "../constants.ts"; -import * as sharedStyles from "../styles/shared.css"; -import type { JSX, PropsWithChildren } from "react"; -import type { AuthenticatedUser } from "../types/clients.ts"; - -// authentication type -export type Authentication = { - user: AuthenticatedUser | null; - isAuthenticated: () => boolean; - refresh: () => void; -}; - -// authentication context -export const AuthenticationContext = createContext({} as Authentication); - -// hook to manage authentication -export const useAuthentication = (): Authentication => { - return useContext(AuthenticationContext); -}; - -// hook to access to authentication -export const useAutenticatedUser = (): any => { - return useContext(AuthenticationContext)?.user; -}; - -// main authentication provider -export const AuthenticationProvider = (props: PropsWithChildren): JSX.Element => { - const clients = useClients(); // get registered clients - const [authenticatedUser, setAuthenticatedUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - // get current token from local storage - // when token changes, this will ensure that the authentication is processed - const token = window.localStorage.getItem(TOKEN_KEY); - - useEffect(() => { - // setAuthenticatedUser(null); - if (clients.authentication && !!token) { - setIsLoading(true); - clients.authentication?.getAuthenticatedUser() - .then((data: AuthenticatedUser) => { - setAuthenticatedUser(data); - }) - .catch((error: any) => { - console.error(error); - clients.authentication?.logout(); - setAuthenticatedUser(null); - }) - .finally(() => { - setIsLoading(false); - }); - } - else { - setIsLoading(false); - } - }, [clients.authentication, setAuthenticatedUser, setIsLoading, token]); - - // authentication manager - const authentication = useMemo(() => { - return { - user: authenticatedUser, - isAuthenticated: () => { - return !!authenticatedUser; - }, - refresh: () => { - setAuthenticatedUser(null); - }, - }; - }, [authenticatedUser, clients, isLoading]); - - // if we are currently getting user information, display a loading screen - // before authentication is finished - if (isLoading) { - return ( - - ); - } - - return ( - - {props.children} - - ); -}; diff --git a/app/contexts/Toaster.tsx b/app/contexts/Toaster.tsx deleted file mode 100644 index 1ddffe8e..00000000 --- a/app/contexts/Toaster.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { createToaster } from "folio-react/factory/ToasterFactory.tsx"; - -export const { useToaster, ToasterProvider } = createToaster(); diff --git a/app/contexts/configuration.tsx b/app/contexts/configuration.tsx deleted file mode 100644 index 9fd45ba1..00000000 --- a/app/contexts/configuration.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useContext, createContext, useState, useEffect } from "react"; -import { Loading } from "folio-react/components/loading.jsx"; -import profile from "@profile"; -import type { JSX, PropsWithChildren } from "react"; - -const ConfigurationContext = createContext(null); - -const getConfigurationFromProfile = (): Promise => { - // 1. check for object - if (typeof profile.config === "object") { - return Promise.resolve(profile.config); - } - // 2. check for promise to resolve configuration - if (typeof profile.config === "function") { - return profile.config(); - } - // 3. return empty object - return Promise.resolve({}); -}; - -// @description hook to get the configuration -export const useConfiguration = (): any => { - return useContext(ConfigurationContext); -}; - -// @description configuration context provider -export const ConfigurationProvider = (props: PropsWithChildren): JSX.Element => { - const [websiteConfig, setWebsiteConfig] = useState(null); - - // on mount, fetch website configuration for the current environment - useEffect(() => { - getConfigurationFromProfile().then(config => { - setWebsiteConfig(config); - }); - }, []); - - // if the website configuration is not yet loaded, we display a loading screen - // to avoid rendering the children before the configuration is available - // this is important because the configuration may contain information that - // is needed to render the children correctly, such as the environment or title - if (!websiteConfig) { - return ( -
- -
- ); - } - - // return the configuration context provider with the website configuration - return ( - - {props.children} - - ); -}; diff --git a/contexts/AppState.tsx b/contexts/AppState.tsx new file mode 100644 index 00000000..94129ad9 --- /dev/null +++ b/contexts/AppState.tsx @@ -0,0 +1,252 @@ +import { createContext, useContext, useEffect, useRef, useCallback, useMemo, useState } from "react"; +import { loadFromJson, saveAsJson } from "folio-react/lib/json.js"; +import { Loading } from "folio-react/components/loading.jsx"; +import { useClients } from "./Clients.tsx"; +import { useAuthentication } from "./Authentication.tsx"; +import type { JSX, PropsWithChildren } from "react"; +import type { StorageDocument, StorageClient } from "../lib/clients/types.ts"; + +export enum Collections { + BOARD = "board", + PREFERENCES = "preferences", +}; + +export enum Dialogs { + LOGIN = "login", + WELCOME = "welcome", + SETTINGS = "settings", +}; + +// application state type +export type AppState = { + ready: boolean; + documents: StorageDocument[], + preferences: any; + activeDialog: Dialogs | null; + activeBoard: string | null; + // create or import documents + createDocument: (data: any, metadata: any) => Promise<{ id: string }>; + importDocument: () => Promise<{ id: string }>; + // manipulating a document + getDocument: (id: string) => Promise; + updateDocument: (id: string, data: any, metadata: any) => Promise; + deleteDocument: (id: string) => Promise; + // additional document actions + downloadDocument: (id: string) => Promise; + duplicateDocument: (id: string) => Promise<{ id: string }>; + // preferences api + getPreferences: () => Promise; + updatePreferences: (preferences: any) => Promise; + // manage active board + openBoard: (id: string) => void; + // manage dialogs + openDialog: (dialog: Dialogs) => void; + closeDialog: () => void; + // handle login/logout + +}; + +// internal app state context +const AppStateContext = createContext({} as AppState); + +// @description hook to use app state +export const useAppState = () => { + return useContext(AppStateContext); +}; + +// @description app state provider +export const AppStateProvider = (props: PropsWithChildren): JSX.Element => { + const [ready, setReady] = useState(false); + const [userDocuments, setUserDocuments] = useState([]); + const [userPreferences, setUserPreferences] = useState(null); + const [activeDialog, setActiveDialog] = useState(null); + const [activeBoard, setActiveBoard] = useState(null); + const clients = useClients(); + const auth = useAuthentication(); + + // save if user is authenticated + const isAuthenticated = auth.isAuthenticated(); + + // get the storage client to use + // note that currently only one of them is enabled + const storageClient = useMemo(() => { + // 1. check if the user is authenticated + if (clients.authentication && clients.remoteStorage && isAuthenticated) { + return clients.remoteStorage; + } + // 2. check if local storage client is configured + else if (clients.localStorage) { + return clients.localStorage; + } + // other case, no storage is available + return null; + }, [clients, isAuthenticated]); + + // internal method to fetch the user preferences + const getUserPreferences = useCallback(async () => { + setUserPreferences(null); + if (storageClient) { + try { + const preferences = await storageClient?.get(Collections.PREFERENCES) + setUserPreferences(preferences?.value || {}); + } + catch (error: any) { + console.error(error); + setUserPreferences({}); + } + } + }, [storageClient, setUserPreferences]); + + // internal method to fetch documents of the logged user + const getUserDocuments = useCallback(async () => { + setUserDocuments([]); // reset user documents before fetching the new ones + if (storageClient) { + const data: any = await storageClient?.list(); + const filteredUserDocuments = (data || []).filter((documentItem: any) => { + return documentItem.id !== Collections.PREFERENCES; + }); + // save filtered user documents + setUserDocuments(filteredUserDocuments); + } + }, [storageClient, setUserDocuments]); + + // initialize app actions + const app = useMemo(() => { + const createDocument = async (data: any = {}, metadata: any = {}) => { + const newDocumentId = ""; // TODO + await storageClient?.create({ + id: newDocumentId, + value: data || {}, + metadata: Object.assign(metadata, { + created_at: Date.now(), + updated_at: Date.now(), + name: metadata?.title || data?.title || "Untitled", + }), + }); + await getUserDocuments(); + return Promise.resolve({ id: newDocumentId }); + }; + return { + activeBoard: activeBoard || null, + activeDialog: activeDialog || null, + documents: userDocuments, + preferences: userPreferences, + createDocument: (data: any = {}, metadata: any = {}) => { + return createDocument(data, metadata); + }, + importDocument: () => { + return loadFromJson().then((data: any) => { + return createDocument(data, {}); + }); + }, + getDocument: (id: string) => { + return storageClient?.get(id); + }, + deleteDocument: async (id: string) => { + await storageClient?.delete(id); + await getUserDocuments(); + }, + updateDocument: async (id: string, data: any, metadata: any) => { + return storageClient?.update(id, { + value: data || {}, + metadata: Object.assign(metadata, { + updated_at: Date.now(), + }), + }); + }, + downloadDocument: async (id: string) => { + const response = await storageClient?.get(id); + if (response?.value) { + return saveAsJson(JSON.parse(response.value as any)); + } + }, + duplicateDocument: async (id: string) => { + const originalResponse = await storageClient?.get(id); + if (originalResponse) { + const newDocumentId = ""; + await storageClient?.create({ + id: newDocumentId, + value: originalResponse?.value || {}, + metadata: Object.assign(originalResponse.metadata || {}, { + name: `Copy of ${originalResponse?.metadata?.name || "Untitled"}`, + updated_at: Date.now(), + }), + }); + await getUserDocuments(); + return Promise.resolve({ id: newDocumentId }); + } + // document not found?? + return Promise.reject(new Error("Document not found")); + }, + getPreferences: () => { + return Promise.resolve(userPreferences || {}); + }, + updatePreferences: async (preferences: any) => { + const newPreferences = Object.assign({}, userPreferences, preferences); + setUserPreferences(newPreferences); // update internal reference to user preferences + return storageClient?.update(Collections.PREFERENCES, { + value: newPreferences, + metadata: { + updated_at: Date.now(), + }, + }); + }, + // getUser: async () => { + // const response = await client.graphql(GET_USER, {}); + // return response?.getUser as User || null; + // }, + // logout: () => { + // // on logout, we just destroy the session. This will automatically + // // display the login screen + // client.logout(); + // }, + openDialog: (dialog: Dialogs) => { + setActiveDialog(dialog); + }, + closeDialog: () => { + setActiveDialog(null); + }, + openBoard: (id: string) => { + setActiveBoard(id); + }, + } as AppState; + }, [storageClient, userDocuments, userPreferences, activeBoard, activeDialog]); + + // when the app is mounted, fetch documents of the logged-in user + useEffect(() => { + const initialize = async () => { + setReady(false); + await getUserPreferences(); + await getUserDocuments(); + setReady(true); + }; + initialize(); + }, [storageClient, getUserDocuments, getUserPreferences, setReady]); + + // if no storage client is available, force to display the login dialog + useEffect(() => { + // if no storage client is available, show the login window + if (!storageClient) { + setActiveBoard(null); + setActiveDialog(Dialogs.LOGIN); + } + // if storage client is available, show the welcome dialog + else { + setActiveDialog(Dialogs.WELCOME); + } + }, [storageClient]); + + // at least user preferences must be provided before continuing to the app + // if (!userPreferences) { + if (!ready) { + return ( + + ); + } + + return ( + + {props.children} + + ); +}; diff --git a/contexts/Authentication.tsx b/contexts/Authentication.tsx new file mode 100644 index 00000000..e86268f9 --- /dev/null +++ b/contexts/Authentication.tsx @@ -0,0 +1,102 @@ +import { useState, useEffect, createContext, useContext, useMemo, useCallback } from "react"; +import { Loading } from "folio-react/components/loading.jsx"; +import { useClients } from "./Clients.tsx"; +import { TOKEN_KEY } from "../constants.ts"; +import type { JSX, PropsWithChildren } from "react"; +import type { AuthenticatedUser } from "../lib/clients/types.ts"; + +// authentication type +export type Authentication = { + user: AuthenticatedUser | null; + isAuthenticated: () => boolean; + login: (credentials: any) => Promise; + logout: () => Promise; +}; + +// authentication context +export const AuthenticationContext = createContext({} as Authentication); + +// hook to manage authentication +export const useAuthentication = (): Authentication => { + return useContext(AuthenticationContext); +}; + +// hook to access to the authenticated user +export const useAutenticatedUser = (): any => { + return useContext(AuthenticationContext)?.user; +}; + +// main authentication provider +export const AuthenticationProvider = (props: PropsWithChildren): JSX.Element => { + const clients = useClients(); + const [token, setToken] = useState(() => window.localStorage.getItem(TOKEN_KEY)); + const [user, setUser] = useState(null); + // loading screen is only displayed while resolving the initial session + const [isLoading, setIsLoading] = useState(true); + + // resolve the user every time the token changes + useEffect(() => { + let cancelled = false; + if (clients.authentication && token) { + clients.authentication.getAuthenticatedUser() + .then((data: AuthenticatedUser) => { + if (!cancelled) { + setUser(data); + } + }) + .catch((error: any) => { + console.error(error); + if (!cancelled) { + clients.authentication?.logout(); + setToken(null); + setUser(null); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + } + else { + setUser(null); + setIsLoading(false); + } + return () => { + cancelled = true; + }; + }, [clients.authentication, token]); + + const login = useCallback(async (credentials: any) => { + await clients.authentication?.login(credentials); + // the client stores the token, we just sync our copy to trigger the effect above + setToken(window.localStorage.getItem(TOKEN_KEY)); + }, [clients.authentication]); + + const logout = useCallback(async () => { + await clients.authentication?.logout(); + setToken(null); + setUser(null); + }, [clients.authentication]); + + const authentication = useMemo(() => { + return { + user: user, + isAuthenticated: () => !!user, + login: login, + logout: logout, + }; + }, [user, login, logout]); + + if (isLoading) { + return ( + + ); + } + + return ( + + {props.children} + + ); +}; diff --git a/app/contexts/Clients.tsx b/contexts/Clients.tsx similarity index 87% rename from app/contexts/Clients.tsx rename to contexts/Clients.tsx index 8121088f..f1c74795 100644 --- a/app/contexts/Clients.tsx +++ b/contexts/Clients.tsx @@ -1,11 +1,10 @@ import { useState, useEffect, createContext, useContext } from "react"; import profile from "@profile"; import { Loading } from "folio-react/components/loading.jsx"; -import { createRemoteStorageClient, createLocalStorageClient } from "../clients/storage.ts"; -import { createAuthenticationClient } from "../clients/authentication.ts"; -import * as sharedStyles from "../styles/shared.css"; +import { createRemoteStorageClient, createLocalStorageClient } from "../lib/clients/storage.ts"; +import { createAuthenticationClient } from "../lib/clients/authentication.ts"; import type { PropsWithChildren, JSX } from "react"; -import type { StorageClient, AuthenticationClient } from "../types/clients.ts"; +import type { StorageClient, AuthenticationClient } from "../lib/clients/types.ts"; export type Clients = { localStorage?: StorageClient | null; @@ -57,7 +56,7 @@ export const ClientsProvider = (props: PropsWithChildren): JSX.Element => { // if clients are not initialized, display a loading screen if (!clients) { return ( - + ); } diff --git a/contexts/Navigation.tsx b/contexts/Navigation.tsx new file mode 100644 index 00000000..4e32712d --- /dev/null +++ b/contexts/Navigation.tsx @@ -0,0 +1,98 @@ +import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useStorage } from "./Storage.tsx"; +import type { JSX, PropsWithChildren } from "react"; +import type { StorageDocument } from "../lib/clients/types.ts"; + +export enum Dialogs { + LOGIN = "login", + WELCOME = "welcome", + SETTINGS = "settings", +}; + +// navigation state type +export type NavigationState = { + activeBoard: string | null; + activeDialog: Dialogs | null; + openBoard: (id: string) => void; + openDialog: (dialog: Dialogs) => void; + closeDialog: () => void; +}; + +const NavigationContext = createContext({} as NavigationState); + +// @description hook to use the navigation +export const useNavigation = (): NavigationState => { + return useContext(NavigationContext); +}; + +// internal method to get the last edited document +const getLastEdited = (documents: StorageDocument[]): StorageDocument | null => { + return documents.reduce((last, item) => { + const isNewer = (item.metadata?.updated_at || 0) > (last?.metadata?.updated_at || 0); + return !last || isNewer ? item : last; + }, null); +}; + +// @description navigation provider +// This provider is remounted every time the storage changes (see StorageProvider), +// so the initial state is all we need to apply the startup and login rules: +// - no storage available: login dialog (the app displays a fake board) +// - storage available (on startup or after login): welcome dialog +export const NavigationProvider = (props: PropsWithChildren): JSX.Element => { + const storage = useStorage(); + const creatingBoard = useRef(false); + const [activeBoard, setActiveBoard] = useState(null); + const [activeDialog, setActiveDialog] = useState(() => { + return storage.type ? Dialogs.WELCOME : Dialogs.LOGIN; + }); + + // Rule: there must always be an active board when a storage is available + // (except while the welcome dialog is displayed, where the user chooses one). + // This covers closing the welcome dialog and deleting the active board: + // open the last edited board or, if there are none, create a new one. + useEffect(() => { + const needsBoard = !!storage.type + && activeDialog !== Dialogs.WELCOME + && !storage.documents.some((item: StorageDocument) => item.id === activeBoard); + if (needsBoard && !creatingBoard.current) { + const lastEdited = getLastEdited(storage.documents); + if (lastEdited) { + setActiveBoard(lastEdited.id); + } + else { + creatingBoard.current = true; + storage.createDocument() + .then(({ id }) => setActiveBoard(id)) + .catch((error: any) => console.error(error)) + .finally(() => { + creatingBoard.current = false; + }); + } + } + }, [storage, activeBoard, activeDialog]); + + const openBoard = useCallback((id: string) => { + setActiveBoard(id); + setActiveDialog(null); + }, []); + + const closeDialog = useCallback(() => { + setActiveDialog(null); + }, []); + + const navigation = useMemo(() => { + return { + activeBoard: activeBoard, + activeDialog: activeDialog, + openBoard: openBoard, + openDialog: setActiveDialog, + closeDialog: closeDialog, + }; + }, [activeBoard, activeDialog, openBoard, closeDialog]); + + return ( + + {props.children} + + ); +}; diff --git a/contexts/Storage.tsx b/contexts/Storage.tsx new file mode 100644 index 00000000..fc94caa6 --- /dev/null +++ b/contexts/Storage.tsx @@ -0,0 +1,185 @@ +import { createContext, useContext, useEffect, useCallback, useMemo, useState } from "react"; +import { loadFromJson, saveAsJson } from "folio-react/lib/json.js"; +import { Loading } from "folio-react/components/loading.jsx"; +import { useClients } from "./Clients.tsx"; +import { useAuthentication } from "./Authentication.tsx"; +import type { JSX, PropsWithChildren } from "react"; +import type { StorageDocument, StorageClient } from "../lib/clients/types.ts"; + +export enum Collections { + BOARD = "board", + PREFERENCES = "preferences", +}; + +export type StorageType = "local" | "remote"; + +// storage state type +export type StorageState = { + type: StorageType | null; + documents: StorageDocument[]; + preferences: any; + // create or import documents + createDocument: (data?: any, metadata?: any) => Promise<{ id: string }>; + importDocument: () => Promise<{ id: string }>; + // manipulating a document + getDocument: (id: string) => Promise; + updateDocument: (id: string, data: any, metadata: any) => Promise; + deleteDocument: (id: string) => Promise; + // additional document actions + downloadDocument: (id: string) => Promise; + duplicateDocument: (id: string) => Promise<{ id: string }>; + // preferences + updatePreferences: (preferences: any) => Promise; +}; + +const StorageContext = createContext({} as StorageState); + +// @description hook to use the storage +export const useStorage = (): StorageState => { + return useContext(StorageContext); +}; + +// @description storage provider +export const StorageProvider = (props: PropsWithChildren): JSX.Element => { + const clients = useClients(); + const auth = useAuthentication(); + const isAuthenticated = auth.isAuthenticated(); + const [documents, setDocuments] = useState([]); + const [preferences, setPreferences] = useState({}); + // client for which documents and preferences have been loaded + // (undefined = nothing loaded yet). The provider is ready when it matches the active client + const [loadedClient, setLoadedClient] = useState(undefined); + + // storage to use: remote if authenticated, local otherwise, none if neither is configured + const { type, client } = useMemo<{ type: StorageType | null; client: StorageClient | null }>(() => { + if (clients.authentication && clients.remoteStorage && isAuthenticated) { + return { type: "remote", client: clients.remoteStorage }; + } + if (clients.localStorage) { + return { type: "local", client: clients.localStorage }; + } + return { type: null, client: null }; + }, [clients, isAuthenticated]); + + const ready = loadedClient === client; + + const fetchDocuments = useCallback(async (): Promise => { + const list: any = await client?.list(); + return (list || []).filter((item: any) => item.id !== Collections.PREFERENCES); + }, [client]); + + // load preferences and documents every time the active client changes + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const [prefs, docs] = await Promise.all([ + client?.get(Collections.PREFERENCES).catch(() => null), + fetchDocuments(), + ]); + if (!cancelled) { + setPreferences(prefs?.value || {}); + setDocuments(docs); + } + } + catch (error) { + console.error(error); + if (!cancelled) { + setPreferences({}); + setDocuments([]); + } + } + finally { + if (!cancelled) { + setLoadedClient(client); + } + } + }; + load(); + return () => { + cancelled = true; + }; + }, [client, fetchDocuments]); + + const storage = useMemo(() => { + const refreshDocuments = async () => { + setDocuments(await fetchDocuments()); + }; + const createDocument = async (data: any = {}, metadata: any = {}) => { + const id = crypto.randomUUID(); + const now = Date.now(); + await client?.create({ + id: id, + value: data || {}, + metadata: { + ...metadata, + created_at: now, + updated_at: now, + name: metadata?.name || metadata?.title || data?.title || "Untitled", + }, + }); + await refreshDocuments(); + return { id: id }; + }; + return { + type: type, + documents: documents, + preferences: preferences, + createDocument: createDocument, + importDocument: async () => { + return createDocument(await loadFromJson()); + }, + getDocument: async (id: string) => { + return client?.get(id) as Promise; + }, + updateDocument: async (id: string, data: any, metadata: any) => { + await client?.update(id, { + value: data || {}, + metadata: { ...metadata, updated_at: Date.now() }, + }); + }, + deleteDocument: async (id: string) => { + await client?.delete(id); + await refreshDocuments(); + }, + downloadDocument: async (id: string) => { + const response = await client?.get(id); + if (response?.value) { + return saveAsJson(JSON.parse(response.value as any)); + } + }, + duplicateDocument: async (id: string) => { + const original = await client?.get(id); + if (!original) { + throw new Error("Document not found"); + } + return createDocument(original.value, { + ...original.metadata, + name: `Copy of ${original.metadata?.name || "Untitled"}`, + }); + }, + updatePreferences: async (changes: any) => { + const newPreferences = { ...preferences, ...changes }; + setPreferences(newPreferences); + await client?.update(Collections.PREFERENCES, { + value: newPreferences, + metadata: { updated_at: Date.now() }, + }); + }, + }; + }, [type, client, documents, preferences, fetchDocuments]); + + // children are unmounted while the storage is (re)loading, so everything below + // (navigation included) always starts from a clean state for each storage + if (!ready) { + return ( + + ); + } + + return ( + + {props.children} + + ); +}; diff --git a/contexts/Toaster.tsx b/contexts/Toaster.tsx new file mode 100644 index 00000000..3042b386 --- /dev/null +++ b/contexts/Toaster.tsx @@ -0,0 +1,111 @@ +import { createContext, useContext, useState, useMemo, useRef } from "react"; +import classNames from "classnames"; +import { renderIcon } from "@josemi-icons/react"; +import type { JSX, PropsWithChildren, CSSProperties } from "react"; + +// amount of time in milliseconds to wait before automatically removing a toast +const TOAST_DURATION = 4000; + +export type Toast = { + message?: string; + messageClassName?: string; + messageStyle?: CSSProperties; + icon?: string; // optional icon + iconClassName?: string; // optional icon class name + iconStyle?: CSSProperties; + duration?: number; // duration in milliseconds +}; + +export type ActiveToast = { + data: Toast | null; + visible: boolean; +}; + +// export the type to display a new toast +export type Toaster = { + success: (message: string) => void; + error: (message: string) => void; + dismiss: () => void; +}; + +// toaster context +export const ToasterContext = createContext({} as Toaster); + +// hook to display a toast message +export const useToaster = (): Toaster => { + return useContext(ToasterContext); +}; + +// Toast component +export const ToastComponent = ({ toast }: { toast: Toast }): JSX.Element => { + const toastClass = classNames({ + "flex items-start gap-2 px-4 py-4 rounded-lg shadow-sm": true, + "bg-gray-950 text-white": true, + }); + return ( +
+ {toast?.icon && ( +
+ {renderIcon(toast.icon)} +
+ )} +
+
+ {toast?.message || ""} +
+
+
+ ); +}; + +export const ToasterProvider = (props: PropsWithChildren): JSX.Element => { + const [ toast, setToast ] = useState({ data: null, visible: false }); + const toastTimmer = useRef(null); + + // Function to add a toast and automatically execute the removal after its duration + const toaster = useMemo(() => { + const createToast = (toast: Toast) => { + clearTimeout(toastTimmer.current); // clear previous timer if exists + setToast({ data: toast, visible: true }); + + // automatically remove the toast after its duration + toastTimmer.current = setTimeout(() => { + setToast({ visible: false, data: toast }); + }, toast?.duration || TOAST_DURATION); + }; + return { + success: (message: string) => { + createToast({ message, icon: "check-circle" }); + }, + error: (message: string) => { + createToast({ message, icon: "x-circle" }); + }, + dismiss: () => { + clearTimeout(toastTimmer.current); + setToast(prevToast => { + return Object.assign({}, prevToast, { visible: false }); + }); + }, + } as Toaster; + }, [ setToast ]); + + // toaster style + const toasterStyle: CSSProperties = { + position: "relative", + transition: "bottom 0.3s linear", + bottom: toast.visible ? "1rem" : "-6rem" , + }; + + return ( + + {props.children} +
+
+ {toast?.data && ( + + )} +
+
+
+ ); +}; From afc8568397bd25f59f78a9bb827173cba7112734 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 10 Oct 2026 23:29:24 +0200 Subject: [PATCH 56/58] Move main components and constants --- app.tsx | 382 +++++++++++++++++++++++++++++++++++++++++++++++ app/constants.ts | 11 -- app/index.tsx | 17 --- constants.ts | 5 + 4 files changed, 387 insertions(+), 28 deletions(-) create mode 100644 app.tsx delete mode 100644 app/constants.ts delete mode 100644 app/index.tsx create mode 100644 constants.ts diff --git a/app.tsx b/app.tsx new file mode 100644 index 00000000..34010dd5 --- /dev/null +++ b/app.tsx @@ -0,0 +1,382 @@ +import { Fragment, useMemo, useCallback, useRef, useEffect, useReducer } from "react"; +import { createRoot } from "react-dom/client"; +import { createMemoryRouter, createHashRouter } from "react-router"; +import { RouterProvider, useParams, Outlet, useOutletContext, Navigate, useNavigate } from "react-router"; +import { loadFromJson, saveAsJson } from "folio-react/lib/json.js"; +import { Loading } from "folio-react/components/loading.jsx"; +import { Centered } from "folio-react/components/ui/centered.tsx"; +import { Overlay, OverlayVariant } from "folio-react/components/ui/overlay.tsx"; +import { ClientsProvider, useClients } from "./contexts/Clients.tsx"; +import { AuthenticationProvider, useAuthentication } from "./contexts/Authentication.tsx"; +import { ToasterProvider, useToaster } from "./contexts/Toaster.tsx"; +import { Welcome } from "./components/Welcome.tsx"; +import { Board } from "./components/Board.tsx"; +import { Login } from "./components/Login.tsx"; +import { Sidebar } from "./components/Sidebar.tsx"; +import type { JSX, PropsWithChildren } from "react"; +import type { Document, Credentials } from "./lib/clients/types.ts"; +import type { SidebarDocument } from "./components/Sidebar.tsx"; + +enum Collections { + BOARD = "board", + PREFERENCES = "preferences", +}; + +// application state type +type AppState = { + version: number; + documents: Document[], + preferences: any; + refresh: () => void; + // create or import documents + createDocument: (data: any, metadata: any) => Promise; + importDocument: () => Promise; + // manipulating a document + getDocument: (id: string) => Promise; + updateDocument: (id: string, data: any, metadata: any) => Promise; + deleteDocument: (id: string) => Promise; + // additional document actions + downloadDocument: (id: string) => Promise; + duplicateDocument: (id: string) => Promise; + // preferences api + getPreferences: () => Promise; + updatePreferences: (preferences: any) => Promise; +}; + +const ProtectedAppContent = (): JSX.Element => { + const [appVersion, incrementAppVersion] = useReducer((x: number): number => x + 1, 0); + const app = useRef({} as AppState); + const clients = useClients(); + const auth = useAuthentication(); + + // save if user is authenticated + const isAuthenticated = auth.isAuthenticated(); + + // get the storage client to use + // note that currently only one of them is enabled + const storageClient = useMemo(() => { + // 1. check if the user is authenticated + if (clients.authentication && clients.remoteStorage && isAuthenticated) { + return clients.remoteStorage; + } + // 2. check if local storage client is configured + else if (clients.localStorage) { + return clients.localStorage; + } + // other case, no storage is available + return null; + }, [clients, isAuthenticated]); + + // update app version in app.current.version + app.current.version = appVersion; + + // internal method to fetch the user preferences + const fetchUserPreferences = useCallback(() => { + app.current.preferences = null; // reset preferences + if (storageClient) { + storageClient?.get(Collections.PREFERENCES) + .then((data: any) => { + app.current.preferences = data?.value || {}; + }) + .catch((error: any) => { + console.error(error); + app.current.preferences = {}; + }) + .finally(() => { + incrementAppVersion(); + }); + } + }, [storageClient, incrementAppVersion]); + + // internal method to fetch documents of the logged user + const fetchUserDocuments = useCallback(() => { + app.current.documents = []; // clean documents reference + if (storageClient) { + storageClient?.list().then((data: any) => { + // Note: we have to exclude special documents + app.current.documents = (data || []).filter((documentItem: any) => { + return documentItem.id !== Collections.PREFERENCES; + }); + incrementAppVersion(); + }); + } + }, [storageClient, incrementAppVersion]); + + // initialize app actions + const appInstance = useMemo(() => { + return Object.assign(app.current, { + refresh: () => { + fetchUserDocuments(); + }, + createDocument: (data: any = {}, metadata: any = {}) => { + return storageClient?.create({ + value: data || {}, + metadata: Object.assign(metadata, { + created_at: Date.now(), + updated_at: Date.now(), + name: metadata?.title || data?.title || "Untitled", + }), + }); + }, + importDocument: () => { + return loadFromJson().then((data: any) => { + return app.current.createDocument(data, {}); + }); + }, + getDocument: (id: string) => { + return storageClient?.get(id); + }, + deleteDocument: async (id: string) => { + return storageClient?.delete(id); + }, + updateDocument: async (id: string, data: any, metadata: any) => { + return storageClient?.update(id, { + value: data || {}, + metadata: Object.assign(metadata, { + updated_at: Date.now(), + }), + }); + }, + downloadDocument: async (id: string) => { + const response = await app.current.getDocument(id); + if (response?.value) { + return saveAsJson(JSON.parse(response.value as any)); + } + }, + duplicateDocument: async (id: string) => { + const originalResponse: Document | null = await app.current.getDocument(id); + if (originalResponse) { + const newDocumentName = `Copy of ${originalResponse?.metadata?.name || "Untitled"}`; + return storageClient?.create({ + value: originalResponse?.value || {}, + metadata: Object.assign(originalResponse.metadata || {}, { + name: newDocumentName, + updated_at: Date.now(), + }), + }); + } + // document not found?? + return Promise.reject(new Error("Document not found")); + }, + getPreferences: () => { + return Promise.resolve(app.current.preferences || {}); + }, + updatePreferences: (preferences: any) => { + app.current.preferences = Object.assign({}, app.current.preferences, preferences); + return storageClient?.update(Collections.PREFERENCES, { + value: app.current.preferences, + metadata: { + updated_at: Date.now(), + }, + }); + }, + // getUser: async () => { + // const response = await client.graphql(GET_USER, {}); + // return response?.getUser as User || null; + // }, + // logout: () => { + // // on logout, we just destroy the session. This will automatically + // // display the login screen + // client.logout(); + // }, + }); + }, [fetchUserDocuments]); + + // when the app is mounted, fetch documents of the logged-in user + useEffect(() => { + if (storageClient) { + fetchUserDocuments(); + fetchUserPreferences(); + } + }, [storageClient, fetchUserDocuments, fetchUserPreferences]); + + // if no storage client is available, redirect to login window + if (!storageClient) { + return ; + } + + // at least user preferences must be provided before continuing to the app + if (!app.current.preferences) { + return ( + + ); + } + + // other case, return the content of the application + return ; +}; + +// mocked app content +const MockedAppContent = (props: PropsWithChildren): JSX.Element => ( + + + + + {props.children} + + +); + +// internal login route +const LoginRoute = (): JSX.Element => { + const auth = useAuthentication(); + const clients = useClients(); + const navigate = useNavigate(); + + // handle login when the user inserts its credentials + const handleLogin = useCallback((credentials: Credentials) => { + if (clients.authentication) { + return clients.authentication.login(credentials).then(() => { + auth.refresh(); + navigate("/"); + }); + } + // no client to handle the authentication, reject the login + return Promise.reject(new Error("No aunthentication client configured")); + }, [auth, clients.authentication]); + + return ( + + + + ); +}; + +// main welcome route +// it displays a welcome message and a list of recent boards +const HomeRoute = (): JSX.Element => { + const app = useOutletContext(); + const navigate = useNavigate(); + + // handle closing the welcome dialog + // it should get the first document saved in the storage. If not, it will create a new document + // and open it + const handleWelcomeDialogClose = useCallback(async () => { + // 1. check if we have at least one document loaded + if (app.documents.length > 0) { + return navigate("/" + app.documents[0].id); + } + // 2. if documents is empty, create a new document + const { id } = await app.createDocument({}, {}); + return navigate("/" + id); + }, [app, navigate]); + + return ( + + + + ); +}; + +// internal board route +// it gets the id from the path and fetches the document from the storage +const BoardRoute = (): JSX.Element => { + const app = useOutletContext(); + const toaster = useToaster(); + const { id } = useParams<{ id: string }>(); + // we have to save a reference to the original document to restore + // metadata when board data changes + const initialDocument = useRef(null); + + return ( + + { + return { + id: documentItem.id, + name: documentItem.metadata?.name || "Untitled", + created_at: documentItem.metadata?.created_at, + updated_at: documentItem.metadata?.updated_at, + } as SidebarDocument; + })} + onDocumentDownload={(id: string) => { + app.downloadDocument(id) + .then(() => { + toaster.success("Document saved."); + }) + .catch(error => { + console.error(error); + }); + }} + onDocumentDuplicate={(id: string) => { + app.duplicateDocument(id) + .then((newDocument) => { + app.refresh(); + toaster.success(`Duplicate saved as '${newDocument?.name || "Untitled"}'.`); + }) + .catch(error => { + console.error(error); + toaster.error(error?.message || "An error occurred while duplicating the document."); + }); + }} + onDocumentDelete={(id: string) => { + console.error("not implemented"); + }} + /> +
+ { + const document = await app.getDocument(id || ""); + if (!document) { + throw new Error(`Board with id "${id}" not found.`); + } + // save the reference to the document to update then + initialDocument.current = document; + return document?.value || {}; + }} + onDataChange={(data: any) => { + const metadata = initialDocument.current?.metadata || {}; + return app.updateDocument(id || "", data, { + ...metadata, + name: data?.title || metadata.name || "Untitled", + }); + }} + onPreferencesChange={(preferences: any) => { + return app.updatePreferences(preferences); + }} + /> +
+
+ ); +}; + +// build application router +const router = createHashRouter([ + { + path: "/login", + Component: LoginRoute, + }, + { + Component: ProtectedAppContent, + children: [ + { + path: "/", + Component: HomeRoute, + }, + { + path: "/:id", + Component: BoardRoute, + }, + ], + }, +]); + +createRoot(document.getElementById("root") as HTMLDivElement).render(( +
+ + + + + + + +
+)); diff --git a/app/constants.ts b/app/constants.ts deleted file mode 100644 index 11fc6420..00000000 --- a/app/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export enum LocalStorageKeys { - BOARD = "data:board", - PREFERENCES = "preferences", -}; - -export enum RemoteStorageKeys { - PREFERENCES = "preferences", -}; - -// key in the local storage to save the authentication token -export const TOKEN_KEY = "folio:token"; diff --git a/app/index.tsx b/app/index.tsx deleted file mode 100644 index 0e8c6f7b..00000000 --- a/app/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { createRoot } from "react-dom/client"; -import { App } from "./components/app.tsx"; -import { ConfigurationProvider } from "./contexts/configuration.tsx"; -import { ClientsProvider } from "./contexts/clients.tsx"; -import { ToasterProvider } from "./contexts/toaster.tsx"; - -createRoot(document.getElementById("root") as HTMLDivElement).render(( -
- - - - - - - -
-)); diff --git a/constants.ts b/constants.ts new file mode 100644 index 00000000..b088fc9e --- /dev/null +++ b/constants.ts @@ -0,0 +1,5 @@ +// key in the local storage to save the authentication token +export const TOKEN_KEY = "folio:token"; + +// default title for new boards +export const DEFAULT_BOARD_TITLE = "Untitled board"; From 1d22fc82e2d875508d3347e01eec68040fdc0dc3 Mon Sep 17 00:00:00 2001 From: Josemi Date: Sat, 10 Oct 2026 23:29:34 +0200 Subject: [PATCH 57/58] Deprecate factory --- folio-react/factory/ToasterFactory.tsx | 117 ------------------------- 1 file changed, 117 deletions(-) delete mode 100644 folio-react/factory/ToasterFactory.tsx diff --git a/folio-react/factory/ToasterFactory.tsx b/folio-react/factory/ToasterFactory.tsx deleted file mode 100644 index bd2d010d..00000000 --- a/folio-react/factory/ToasterFactory.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { createContext, useContext, useState, useMemo, useRef } from "react"; -import classNames from "classnames"; -import { renderIcon } from "@josemi-icons/react"; -import * as styles from "../styles/contexts.css"; - -import type { JSX, PropsWithChildren, CSSProperties } from "react"; - -// amount of time in milliseconds to wait before automatically removing a toast -const TOAST_DURATION = 4000; - -export type Toast = { - message?: string; - messageClassName?: string; - messageStyle?: CSSProperties; - icon?: string; // optional icon - iconClassName?: string; // optional icon class name - iconStyle?: CSSProperties; - duration?: number; // duration in milliseconds -}; - -export type ActiveToast = { - data: Toast | null; - visible: boolean; -}; - -// export the type to display a new toast -export type Toaster = { - success: (message: string) => void; - error: (message: string) => void; - dismiss: () => void; -}; - -export type ToasterFactory = { - useToaster: () => Toaster; - ToasterProvider: (props: PropsWithChildren) => JSX.Element; -}; - -// Toast component -const ToastComponent = ({ toast }: { toast: Toast }): JSX.Element => ( -
- {toast?.icon && ( -
- {renderIcon(toast.icon)} -
- )} -
-
- {toast?.message || ""} -
-
-
-); - -// toaster factory -export const createToaster = (): ToasterFactory => { - const ToasterContext = createContext({} as Toaster); - - // hook to display a toast message - const useToaster = (): Toaster => { - return useContext(ToasterContext); - }; - - // ToasterProvider component - const ToasterProvider = (props: PropsWithChildren): JSX.Element => { - const [ toast, setToast ] = useState({ data: null, visible: false }); - const toastTimmer = useRef(null); - - // Function to add a toast and automatically execute the removal after its duration - const toaster = useMemo(() => { - const createToast = (toast: Toast) => { - clearTimeout(toastTimmer.current); // clear previous timer if exists - setToast({ data: toast, visible: true }); - - // automatically remove the toast after its duration - toastTimmer.current = setTimeout(() => { - setToast({ visible: false, data: toast }); - }, toast?.duration || TOAST_DURATION); - }; - return { - success: (message: string) => { - createToast({ message, icon: "check-circle" }); - }, - error: (message: string) => { - createToast({ message, icon: "x-circle" }); - }, - dismiss: () => { - clearTimeout(toastTimmer.current); - setToast(prevToast => { - return Object.assign({}, prevToast, { visible: false }); - }); - }, - } as Toaster; - }, [ setToast ]); - - // toaster style - const toasterStyle: CSSProperties = { - position: "relative", - transition: "bottom 0.3s linear", - bottom: toast.visible ? "1rem" : "-6rem" , - }; - - return ( - - {props.children} -
-
- {toast?.data && ( - - )} -
-
-
- ); - }; - - return { useToaster, ToasterProvider }; -}; From 9353e4977f6e13e3b1e4d25206923d3825b06bd6 Mon Sep 17 00:00:00 2001 From: Josemi Date: Wed, 14 Oct 2026 18:43:05 +0200 Subject: [PATCH 58/58] Initialize router context --- contexts/Navigation.tsx | 98 ----------------------------------------- contexts/Router.tsx | 47 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 98 deletions(-) delete mode 100644 contexts/Navigation.tsx create mode 100644 contexts/Router.tsx diff --git a/contexts/Navigation.tsx b/contexts/Navigation.tsx deleted file mode 100644 index 4e32712d..00000000 --- a/contexts/Navigation.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useStorage } from "./Storage.tsx"; -import type { JSX, PropsWithChildren } from "react"; -import type { StorageDocument } from "../lib/clients/types.ts"; - -export enum Dialogs { - LOGIN = "login", - WELCOME = "welcome", - SETTINGS = "settings", -}; - -// navigation state type -export type NavigationState = { - activeBoard: string | null; - activeDialog: Dialogs | null; - openBoard: (id: string) => void; - openDialog: (dialog: Dialogs) => void; - closeDialog: () => void; -}; - -const NavigationContext = createContext({} as NavigationState); - -// @description hook to use the navigation -export const useNavigation = (): NavigationState => { - return useContext(NavigationContext); -}; - -// internal method to get the last edited document -const getLastEdited = (documents: StorageDocument[]): StorageDocument | null => { - return documents.reduce((last, item) => { - const isNewer = (item.metadata?.updated_at || 0) > (last?.metadata?.updated_at || 0); - return !last || isNewer ? item : last; - }, null); -}; - -// @description navigation provider -// This provider is remounted every time the storage changes (see StorageProvider), -// so the initial state is all we need to apply the startup and login rules: -// - no storage available: login dialog (the app displays a fake board) -// - storage available (on startup or after login): welcome dialog -export const NavigationProvider = (props: PropsWithChildren): JSX.Element => { - const storage = useStorage(); - const creatingBoard = useRef(false); - const [activeBoard, setActiveBoard] = useState(null); - const [activeDialog, setActiveDialog] = useState(() => { - return storage.type ? Dialogs.WELCOME : Dialogs.LOGIN; - }); - - // Rule: there must always be an active board when a storage is available - // (except while the welcome dialog is displayed, where the user chooses one). - // This covers closing the welcome dialog and deleting the active board: - // open the last edited board or, if there are none, create a new one. - useEffect(() => { - const needsBoard = !!storage.type - && activeDialog !== Dialogs.WELCOME - && !storage.documents.some((item: StorageDocument) => item.id === activeBoard); - if (needsBoard && !creatingBoard.current) { - const lastEdited = getLastEdited(storage.documents); - if (lastEdited) { - setActiveBoard(lastEdited.id); - } - else { - creatingBoard.current = true; - storage.createDocument() - .then(({ id }) => setActiveBoard(id)) - .catch((error: any) => console.error(error)) - .finally(() => { - creatingBoard.current = false; - }); - } - } - }, [storage, activeBoard, activeDialog]); - - const openBoard = useCallback((id: string) => { - setActiveBoard(id); - setActiveDialog(null); - }, []); - - const closeDialog = useCallback(() => { - setActiveDialog(null); - }, []); - - const navigation = useMemo(() => { - return { - activeBoard: activeBoard, - activeDialog: activeDialog, - openBoard: openBoard, - openDialog: setActiveDialog, - closeDialog: closeDialog, - }; - }, [activeBoard, activeDialog, openBoard, closeDialog]); - - return ( - - {props.children} - - ); -}; diff --git a/contexts/Router.tsx b/contexts/Router.tsx new file mode 100644 index 00000000..0c64eee0 --- /dev/null +++ b/contexts/Router.tsx @@ -0,0 +1,47 @@ +import { useContext, createContext, useCallback, useState, useEffect } from "react"; +import type { JSX, PropsWithChildren } from "react"; + +// @description get current hash fragment +const getCurrentHash = (): string => { + return (window.location.hash || "#").replace("#", ""); +}; + +// internal router context +export const RouterContext = createContext(""); + +// @description hook to access to the current location +export const useLocation = (): string => { + return useContext(RouterContext); +}; + +// @description hook to create a navigation to redirect to a new path +export const useNavigate = () => { + return useCallback((to: string) => { + window.location.hash = to || ""; + }, []); +}; + +// main router provider +export const RouterProvider = (props: PropsWithChildren): JSX.Element => { + const [currentLocation, setCurrentLocation] = useState(() => { + return getCurrentHash(); + }); + + // hook to listen for changes in the hash + useEffect(() => { + const handleHashChange = () => { + setCurrentLocation(getCurrentHash()); + }; + window.addEventListener("hashchange", handleHashChange); + // when app is unmounted, remove hashchange listener + return () => { + window.removeEventListener("hashchange", handleHashChange); + }; + }, [setCurrentLocation]); + + return ( + + {props.children} + + ); +};