Rework #6
6
.gitignore
vendored
6
.gitignore
vendored
@ -12,6 +12,10 @@ db.sqlite3
|
||||
build/**
|
||||
app/**
|
||||
settings.json
|
||||
*config.json
|
||||
comic_config.json
|
||||
**/comic_config.json
|
||||
compiled/
|
||||
deploy-scripts/
|
||||
|
||||
.pnpm-store/**
|
||||
.env
|
143
app.ts
143
app.ts
@ -1,143 +0,0 @@
|
||||
import { app, BrowserWindow, dialog, session } from "electron";
|
||||
import { ipcMain } from "electron";
|
||||
import { join } from "path";
|
||||
import { accessTokenName, getAdminAccessTokenValue, getAdminRefreshTokenValue, refreshTokenName } from "./src/login";
|
||||
import { UserAccessor } from "./src/model/mod";
|
||||
import { create_server } from "./src/server";
|
||||
import { get_setting } from "./src/SettingConfig";
|
||||
|
||||
function registerChannel(cntr: UserAccessor) {
|
||||
ipcMain.handle("reset_password", async (event, username: string, password: string) => {
|
||||
const user = await cntr.findUser(username);
|
||||
if (user === undefined) {
|
||||
return false;
|
||||
}
|
||||
user.reset_password(password);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
const setting = get_setting();
|
||||
if (!setting.cli) {
|
||||
let wnd: BrowserWindow | null = null;
|
||||
|
||||
const createWindow = async () => {
|
||||
wnd = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
center: true,
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
},
|
||||
});
|
||||
await wnd.loadURL(`data:text/html;base64,` + Buffer.from(loading_html).toString("base64"));
|
||||
// await wnd.loadURL('../loading.html');
|
||||
// set admin cookies.
|
||||
await session.defaultSession.cookies.set({
|
||||
url: `http://localhost:${setting.port}`,
|
||||
name: accessTokenName,
|
||||
value: getAdminAccessTokenValue(),
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
});
|
||||
await session.defaultSession.cookies.set({
|
||||
url: `http://localhost:${setting.port}`,
|
||||
name: refreshTokenName,
|
||||
value: getAdminRefreshTokenValue(),
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
});
|
||||
try {
|
||||
const server = await create_server();
|
||||
const app = server.start_server();
|
||||
registerChannel(server.userController);
|
||||
await wnd.loadURL(`http://localhost:${setting.port}`);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
title: "error!",
|
||||
message: e.message,
|
||||
});
|
||||
} else {
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
title: "error!",
|
||||
message: String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
wnd.on("closed", () => {
|
||||
wnd = null;
|
||||
});
|
||||
};
|
||||
|
||||
const isPrimary = app.requestSingleInstanceLock();
|
||||
if (!isPrimary) {
|
||||
app.quit(); // exit window
|
||||
app.exit();
|
||||
}
|
||||
app.on("second-instance", () => {
|
||||
if (wnd != null) {
|
||||
if (wnd.isMinimized()) {
|
||||
wnd.restore();
|
||||
}
|
||||
wnd.focus();
|
||||
}
|
||||
});
|
||||
app.on("ready", (event, info) => {
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => { // quit when all windows are closed
|
||||
if (process.platform != "darwin") app.quit(); // (except leave MacOS app active until Cmd+Q)
|
||||
});
|
||||
|
||||
app.on("activate", () => { // re-recreate window when dock icon is clicked and no other windows open
|
||||
if (wnd == null) createWindow();
|
||||
});
|
||||
} else {
|
||||
(async () => {
|
||||
try {
|
||||
const server = await create_server();
|
||||
server.start_server();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
const loading_html = `<!DOCTYPE html>
|
||||
<html lang="ko"><head>
|
||||
<meta charset="UTF-8">
|
||||
<title>loading</title>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'
|
||||
fonts.googleapis.com; font-src 'self' fonts.gstatic.com">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<style>
|
||||
body { margin-top: 100px; background-color: #3f51b5; color: #fff; text-align:center; }
|
||||
h1 {
|
||||
font: 2em 'Roboto', sans-serif;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
#loading {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid rgba(255,255,255,.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg);}
|
||||
}
|
||||
</style>
|
||||
<body>
|
||||
<h1>Loading...</h1>
|
||||
<div id="loading"></div>
|
||||
</body>
|
||||
</html>`;
|
21
biome.jsonc
Normal file
21
biome.jsonc
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.6.3/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"lineWidth": 120
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
}
|
||||
}
|
23
dprint.json
23
dprint.json
@ -1,23 +0,0 @@
|
||||
{
|
||||
"incremental": true,
|
||||
"typescript": {
|
||||
"indentWidth": 2
|
||||
},
|
||||
"json": {
|
||||
},
|
||||
"markdown": {
|
||||
},
|
||||
"includes": ["**/*.{ts,tsx,js,jsx,cjs,mjs,json,md}"],
|
||||
"excludes": [
|
||||
"**/node_modules",
|
||||
"**/*-lock.json",
|
||||
"**/dist",
|
||||
"build/",
|
||||
"app/"
|
||||
],
|
||||
"plugins": [
|
||||
"https://plugins.dprint.dev/typescript-0.84.4.wasm",
|
||||
"https://plugins.dprint.dev/json-0.17.2.wasm",
|
||||
"https://plugins.dprint.dev/markdown-0.15.2.wasm"
|
||||
]
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import { promises } from "fs";
|
||||
const { readdir, writeFile } = promises;
|
||||
import { dirname, join } from "path";
|
||||
import { createGenerator } from "ts-json-schema-generator";
|
||||
|
||||
async function genSchema(path: string, typename: string) {
|
||||
const gen = createGenerator({
|
||||
path: path,
|
||||
type: typename,
|
||||
tsconfig: "tsconfig.json",
|
||||
});
|
||||
const schema = gen.createSchema(typename);
|
||||
if (schema.definitions != undefined) {
|
||||
const definitions = schema.definitions;
|
||||
const definition = definitions[typename];
|
||||
if (typeof definition == "object") {
|
||||
let property = definition.properties;
|
||||
if (property) {
|
||||
property["$schema"] = {
|
||||
type: "string",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const text = JSON.stringify(schema);
|
||||
await writeFile(join(dirname(path), `${typename}.schema.json`), text);
|
||||
}
|
||||
function capitalize(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
async function setToALL(path: string) {
|
||||
console.log(`scan ${path}`);
|
||||
const direntry = await readdir(path, { withFileTypes: true });
|
||||
const works = direntry.filter(x => x.isFile() && x.name.endsWith("Config.ts")).map(x => {
|
||||
const name = x.name;
|
||||
const m = /(.+)\.ts/.exec(name);
|
||||
if (m !== null) {
|
||||
const typename = m[1];
|
||||
return genSchema(join(path, typename), capitalize(typename));
|
||||
}
|
||||
});
|
||||
await Promise.all(works);
|
||||
const subdir = direntry.filter(x => x.isDirectory()).map(x => x.name);
|
||||
for (const x of subdir) {
|
||||
await setToALL(join(path, x));
|
||||
}
|
||||
}
|
||||
setToALL("src");
|
17
index.html
17
index.html
@ -1,17 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Ionian</title>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' fonts.googleapis.com;
|
||||
font-src 'self' fonts.gstatic.com">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/dist/bundle.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
|
||||
<!--MetaTag-Outlet-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/dist/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
@ -1,5 +0,0 @@
|
||||
require("ts-node").register();
|
||||
const { Knex } = require("./src/config");
|
||||
// Update with your config settings.
|
||||
|
||||
module.exports = Knex.config;
|
@ -1,54 +0,0 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex) {
|
||||
await knex.schema.createTable("schema_migration", (b) => {
|
||||
b.string("version");
|
||||
b.boolean("dirty");
|
||||
});
|
||||
|
||||
await knex.schema.createTable("users", (b) => {
|
||||
b.string("username").primary().comment("user's login id");
|
||||
b.string("password_hash", 64).notNullable();
|
||||
b.string("password_salt", 64).notNullable();
|
||||
});
|
||||
await knex.schema.createTable("document", (b) => {
|
||||
b.increments("id").primary();
|
||||
b.string("title").notNullable();
|
||||
b.string("content_type", 16).notNullable();
|
||||
b.string("basepath", 256).notNullable().comment("directory path for resource");
|
||||
b.string("filename", 256).notNullable().comment("filename");
|
||||
b.string("content_hash").nullable();
|
||||
b.json("additional").nullable();
|
||||
b.integer("created_at").notNullable();
|
||||
b.integer("modified_at").notNullable();
|
||||
b.integer("deleted_at");
|
||||
b.index("content_type", "content_type_index");
|
||||
});
|
||||
await knex.schema.createTable("tags", (b) => {
|
||||
b.string("name").primary();
|
||||
b.text("description");
|
||||
});
|
||||
await knex.schema.createTable("doc_tag_relation", (b) => {
|
||||
b.integer("doc_id").unsigned().notNullable();
|
||||
b.string("tag_name").notNullable();
|
||||
b.foreign("doc_id").references("document.id");
|
||||
b.foreign("tag_name").references("tags.name");
|
||||
b.primary(["doc_id", "tag_name"]);
|
||||
});
|
||||
await knex.schema.createTable("permissions", b => {
|
||||
b.string("username").notNullable();
|
||||
b.string("name").notNullable();
|
||||
b.primary(["username", "name"]);
|
||||
b.foreign("username").references("users.username");
|
||||
});
|
||||
// create admin account.
|
||||
await knex.insert({
|
||||
username: "admin",
|
||||
password_hash: "unchecked",
|
||||
password_salt: "unchecked",
|
||||
}).into("users");
|
||||
}
|
||||
|
||||
export async function down(knex: Knex) {
|
||||
throw new Error("Downward migrations are not supported. Restore from backup.");
|
||||
}
|
86
package.json
86
package.json
@ -1,86 +1,20 @@
|
||||
{
|
||||
"name": "followed",
|
||||
"name": "ionian",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "build/app.js",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"compile": "tsc",
|
||||
"compile:watch": "tsc -w",
|
||||
"build": "cd src/client && pnpm run build:prod",
|
||||
"build:watch": "cd src/client && pnpm run build:watch",
|
||||
"fmt": "dprint fmt",
|
||||
"app": "electron build/app.js",
|
||||
"app:build": "electron-builder",
|
||||
"app:pack": "electron-builder --dir",
|
||||
"app:build:win64": "electron-builder --win --x64",
|
||||
"app:pack:win64": "electron-builder --win --x64 --dir",
|
||||
"cliapp": "node build/app.js"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"format": "biome format --write",
|
||||
"lint": "biome lint"
|
||||
},
|
||||
"build": {
|
||||
"asar": true,
|
||||
"files": [
|
||||
"build/**/*",
|
||||
"node_modules/**/*",
|
||||
"package.json"
|
||||
"keywords": [],
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "dist/",
|
||||
"to": "dist/",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!**/*.map"
|
||||
]
|
||||
},
|
||||
"index.html"
|
||||
],
|
||||
"appId": "com.prelude.ionian.app",
|
||||
"productName": "Ionian",
|
||||
"win": {
|
||||
"target": [
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
"directories": {
|
||||
"output": "app/",
|
||||
"app": "."
|
||||
}
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@louislam/sqlite3": "^6.0.1",
|
||||
"@types/koa-compose": "^3.2.5",
|
||||
"chokidar": "^3.5.3",
|
||||
"dprint": "^0.36.1",
|
||||
"jsonschema": "^1.4.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"knex": "^0.95.15",
|
||||
"koa": "^2.13.4",
|
||||
"koa-bodyparser": "^4.3.0",
|
||||
"koa-compose": "^4.1.0",
|
||||
"koa-router": "^10.1.1",
|
||||
"natural-orderby": "^2.0.3",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"sqlite3": "^5.0.8",
|
||||
"tiny-async-pool": "^1.3.0"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/koa": "^2.13.4",
|
||||
"@types/koa-bodyparser": "^4.3.7",
|
||||
"@types/koa-router": "^7.4.4",
|
||||
"@types/node": "^14.18.21",
|
||||
"@types/tiny-async-pool": "^1.0.1",
|
||||
"electron": "^11.5.0",
|
||||
"electron-builder": "^22.14.13",
|
||||
"ts-json-schema-generator": "^0.82.0",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.7.4"
|
||||
"@biomejs/biome": "1.6.3"
|
||||
}
|
||||
}
|
18
packages/client/.eslintrc.cjs
Normal file
18
packages/client/.eslintrc.cjs
Normal file
@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
24
packages/client/.gitignore
vendored
Normal file
24
packages/client/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
30
packages/client/README.md
Normal file
30
packages/client/README.md
Normal file
@ -0,0 +1,30 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
17
packages/client/components.json
Normal file
17
packages/client/components.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
13
packages/client/index.html
Normal file
13
packages/client/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ionian</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
52
packages/client/package.json
Normal file
52
packages/client/package.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"shadcn": "shadcn-ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-virtual": "^3.2.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"dbtype": "workspace:*",
|
||||
"jotai": "^2.7.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable-panels": "^2.0.16",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"wouter": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"postcss": "^8.4.38",
|
||||
"shadcn-ui": "^0.8.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
6
packages/client/postcss.config.js
Normal file
6
packages/client/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
1
packages/client/public/vite.svg
Normal file
1
packages/client/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
13
packages/client/src/App.css
Normal file
13
packages/client/src/App.css
Normal file
@ -0,0 +1,13 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100..900&display=swap');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Noto Sans KR", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
49
packages/client/src/App.tsx
Normal file
49
packages/client/src/App.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { Route, Switch, Redirect } from "wouter";
|
||||
import { useTernaryDarkMode } from "usehooks-ts";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import './App.css'
|
||||
|
||||
import { TooltipProvider } from "./components/ui/tooltip.tsx";
|
||||
import Layout from "./components/layout/layout.tsx";
|
||||
|
||||
import Gallery from "@/page/galleryPage.tsx";
|
||||
import NotFoundPage from "@/page/404.tsx";
|
||||
import LoginPage from "@/page/loginPage.tsx";
|
||||
import ProfilePage from "@/page/profilesPage.tsx";
|
||||
import ContentInfoPage from "@/page/contentInfoPage.tsx";
|
||||
import SettingPage from "@/page/settingPage.tsx";
|
||||
import ComicPage from "@/page/reader/comicPage.tsx";
|
||||
import DifferencePage from "./page/differencePage.tsx";
|
||||
|
||||
const App = () => {
|
||||
const { isDarkMode } = useTernaryDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
if (isDarkMode) {
|
||||
document.body.classList.add("dark");
|
||||
}
|
||||
else {
|
||||
document.body.classList.remove("dark");
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Route path="/" component={() => <Redirect replace to="/search?" />} />
|
||||
<Route path="/search" component={Gallery} />
|
||||
<Route path="/login" component={LoginPage} />
|
||||
<Route path="/profile" component={ProfilePage}/>
|
||||
<Route path="/doc/:id" component={ContentInfoPage}/>
|
||||
<Route path="/setting" component={SettingPage} />
|
||||
<Route path="/doc/:id/reader" component={ComicPage}/>
|
||||
<Route path="/difference" component={DifferencePage}/>
|
||||
<Route component={NotFoundPage} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
</TooltipProvider>);
|
||||
};
|
||||
|
||||
export default App
|
1
packages/client/src/assets/react.svg
Normal file
1
packages/client/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4.0 KiB |
25
packages/client/src/components/Spinner.tsx
Normal file
25
packages/client/src/components/Spinner.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
|
||||
export function Spinner(props: { className?: string; }) {
|
||||
const chars = ["⠋",
|
||||
"⠙",
|
||||
"⠹",
|
||||
"⠸",
|
||||
"⠼",
|
||||
"⠴",
|
||||
"⠦",
|
||||
"⠧",
|
||||
"⠇",
|
||||
"⠏"
|
||||
];
|
||||
const [index, setIndex] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setIndex((index + 1) % chars.length);
|
||||
}, 80);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [index]);
|
||||
|
||||
return <span className={props.className}>{chars[index]}</span>;
|
||||
}
|
26
packages/client/src/components/gallery/DescItem.tsx
Normal file
26
packages/client/src/components/gallery/DescItem.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import StyledLink from "@/components/gallery/StyledLink";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DescItem({ name, children, className }: {
|
||||
name: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return <div className={cn("grid content-start", className)}>
|
||||
<span className="text-muted-foreground text-sm">{name}</span>
|
||||
<span className="text-primary leading-4 font-medium">{children}</span>
|
||||
</div>;
|
||||
}
|
||||
export function DescTagItem({
|
||||
items, name, className,
|
||||
}: {
|
||||
name: string;
|
||||
items: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
return <DescItem name={name} className={className}>
|
||||
{items.length === 0 ? "N/A" : items.map(
|
||||
(x) => <StyledLink key={x} to={`/search?allow_tag=${name}:${x}`}>{x}</StyledLink>
|
||||
)}
|
||||
</DescItem>;
|
||||
}
|
90
packages/client/src/components/gallery/GalleryCard.tsx
Normal file
90
packages/client/src/components/gallery/GalleryCard.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import type { Document } from "dbtype/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
|
||||
import TagBadge from "@/components/gallery/TagBadge.tsx";
|
||||
import { Fragment, useLayoutEffect, useRef, useState } from "react";
|
||||
import { LazyImage } from "./LazyImage.tsx";
|
||||
import StyledLink from "./StyledLink.tsx";
|
||||
import React from "react";
|
||||
|
||||
function clipTagsWhenOverflow(tags: string[], limit: number) {
|
||||
let l = 0;
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
l += tags[i].length;
|
||||
if (l > limit) {
|
||||
return tags.slice(0, i);
|
||||
}
|
||||
l += 1; // for space
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function GalleryCardImpl({
|
||||
doc: x
|
||||
}: { doc: Document; }) {
|
||||
const ref = useRef<HTMLUListElement>(null);
|
||||
const [clipCharCount, setClipCharCount] = useState(200);
|
||||
const isDeleted = x.deleted_at !== null;
|
||||
|
||||
const artists = x.tags.filter(x => x.startsWith("artist:")).map(x => x.replace("artist:", ""));
|
||||
const groups = x.tags.filter(x => x.startsWith("group:")).map(x => x.replace("group:", ""));
|
||||
|
||||
const originalTags = x.tags.filter(x => !x.startsWith("artist:") && !x.startsWith("group:"));
|
||||
const clippedTags = clipTagsWhenOverflow(originalTags, clipCharCount);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const listener = () => {
|
||||
if (ref.current) {
|
||||
const { width } = ref.current.getBoundingClientRect();
|
||||
const charWidth = 7; // rough estimate
|
||||
const newClipCharCount = Math.floor(width / charWidth) * 3;
|
||||
setClipCharCount(newClipCharCount);
|
||||
}
|
||||
};
|
||||
listener();
|
||||
window.addEventListener("resize", listener);
|
||||
return () => {
|
||||
window.removeEventListener("resize", listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Card className="flex h-[200px]">
|
||||
{isDeleted ? <div className="bg-primary border flex items-center justify-center h-[200px] w-[142px] rounded-xl">
|
||||
<span className="text-primary-foreground text-lg font-bold">Deleted</span>
|
||||
</div> : <div className="rounded-xl overflow-hidden h-[200px] w-[142px] flex-none bg-[#272733] flex items-center justify-center">
|
||||
<LazyImage src={`/api/doc/${x.id}/comic/thumbnail`}
|
||||
alt={x.title}
|
||||
className="max-h-full max-w-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<CardHeader className="flex-none">
|
||||
<CardTitle>
|
||||
<StyledLink className="line-clamp-2" to={`/doc/${x.id}`}>
|
||||
{x.title}
|
||||
</StyledLink>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{artists.map((x, i) => <Fragment key={`artist:${x}`}>
|
||||
<StyledLink to={`/search?allow_tag=artist:${x}`}>{x}</StyledLink>
|
||||
{i + 1 < artists.length && <span className="opacity-50">, </span>}
|
||||
</Fragment>)}
|
||||
{groups.length > 0 && <span key={"sep"}>{" | "}</span>}
|
||||
{groups.map((x, i) => <Fragment key={`group:${x}`}>
|
||||
<StyledLink to={`/search?allow_tag=group:${x}`}>{x}</StyledLink>
|
||||
{i + 1 < groups.length && <span className="opacity-50">, </span>}
|
||||
</Fragment>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden">
|
||||
<ul ref={ref} className="flex flex-wrap gap-2 items-baseline content-start">
|
||||
{clippedTags.map(tag => <TagBadge key={tag} tagname={tag} className="" />)}
|
||||
{clippedTags.length < originalTags.length && <TagBadge key={"..."} tagname="..." className="inline-block" disabled />}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
export const GalleryCard = React.memo(GalleryCardImpl);
|
38
packages/client/src/components/gallery/LazyImage.tsx
Normal file
38
packages/client/src/components/gallery/LazyImage.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function LazyImage({ src, alt, className }: { src: string; alt: string; className?: string; }) {
|
||||
const ref = useRef<HTMLImageElement>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some(x => x.isIntersecting)) {
|
||||
setLoaded(true);
|
||||
ref.current?.animate([
|
||||
{ opacity: 0 },
|
||||
{ opacity: 1 }
|
||||
], {
|
||||
duration: 300,
|
||||
easing: "ease-in-out"
|
||||
});
|
||||
observer.disconnect();
|
||||
}
|
||||
}, {
|
||||
rootMargin: "200px",
|
||||
threshold: 0
|
||||
});
|
||||
observer.observe(ref.current);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <img
|
||||
ref={ref}
|
||||
src={loaded ? src : undefined}
|
||||
alt={alt}
|
||||
className={className}
|
||||
loading="lazy" />;
|
||||
}
|
14
packages/client/src/components/gallery/StyledLink.tsx
Normal file
14
packages/client/src/components/gallery/StyledLink.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Link } from "wouter";
|
||||
|
||||
type StyledLinkProps = {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
export default function StyledLink({ children, className, ...rest }: StyledLinkProps) {
|
||||
return <Link {...rest}
|
||||
className={cn("hover:underline underline-offset-1 rounded-sm focus-visible:ring-1 focus-visible:ring-ring", className)}
|
||||
>{children}</Link>
|
||||
}
|
88
packages/client/src/components/gallery/TagBadge.tsx
Normal file
88
packages/client/src/components/gallery/TagBadge.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { badgeVariants } from "@/components/ui/badge.tsx";
|
||||
import { Link } from "wouter";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
enum TagKind {
|
||||
Default = "default",
|
||||
Type = "type",
|
||||
Character = "character",
|
||||
Series = "series",
|
||||
Group = "group",
|
||||
Artist = "artist",
|
||||
Male = "male",
|
||||
Female = "female",
|
||||
}
|
||||
|
||||
type TagKindType = `${TagKind}`;
|
||||
|
||||
export function getTagKind(tagname: string): TagKindType {
|
||||
if (tagname.match(":") === null) {
|
||||
return "default";
|
||||
}
|
||||
const prefix = tagname.split(":")[0];
|
||||
return prefix as TagKindType;
|
||||
}
|
||||
|
||||
export function toPrettyTagname(tagname: string): string {
|
||||
const kind = getTagKind(tagname);
|
||||
const name = tagname.slice(kind.length + 1);
|
||||
|
||||
switch (kind) {
|
||||
case "male":
|
||||
return `♂ ${name}`;
|
||||
case "female":
|
||||
return `♀ ${name}`;
|
||||
case "artist":
|
||||
return `🎨 ${name}`;
|
||||
case "group":
|
||||
return `🖿 ${name}`;
|
||||
case "series":
|
||||
return `📚 ${name}`
|
||||
case "character":
|
||||
return `👤 ${name}`;
|
||||
case "default":
|
||||
return tagname;
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
interface TagBadgeProps {
|
||||
tagname: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
export const tagBadgeVariants = cva(
|
||||
cn(badgeVariants({ variant: "default"}), "px-1"),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-[#4a5568] hover:bg-[#718096]",
|
||||
type: "bg-[#d53f8c] hover:bg-[#e24996]",
|
||||
character: "bg-[#52952c] hover:bg-[#6cc24a]",
|
||||
series: "bg-[#dc8f09] hover:bg-[#e69d17]",
|
||||
group: "bg-[#805ad5] hover:bg-[#8b5cd6]",
|
||||
artist: "bg-[#319795] hover:bg-[#38a89d]",
|
||||
female: "bg-[#c21f58] hover:bg-[#db2d67]",
|
||||
male: "bg-[#2a7bbf] hover:bg-[#3091e7]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default function TagBadge(props: TagBadgeProps) {
|
||||
const { tagname } = props;
|
||||
const kind = getTagKind(tagname);
|
||||
return <li className={
|
||||
cn( tagBadgeVariants({ variant: kind }),
|
||||
props.disabled && "opacity-50",
|
||||
props.className,
|
||||
)
|
||||
}><Link to={props.disabled ? '': `/search?allow_tag=${tagname}`}>{toPrettyTagname(tagname)}</Link></li>;
|
||||
}
|
182
packages/client/src/components/gallery/TagInput.tsx
Normal file
182
packages/client/src/components/gallery/TagInput.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getTagKind, tagBadgeVariants } from "./TagBadge";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { useOnClickOutside } from "usehooks-ts";
|
||||
import { useTags } from "@/hook/useTags";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
interface TagsSelectListProps {
|
||||
className?: string;
|
||||
search?: string;
|
||||
onSelect?: (tag: string) => void;
|
||||
onFirstArrowUp?: () => void;
|
||||
}
|
||||
|
||||
function TagsSelectList({
|
||||
search = "",
|
||||
onSelect,
|
||||
onFirstArrowUp = () => { },
|
||||
}: TagsSelectListProps) {
|
||||
const { data, isLoading } = useTags();
|
||||
const candidates = data?.filter(s => s.name.startsWith(search));
|
||||
|
||||
return <ul className="max-h-[400px] overflow-scroll overflow-x-hidden">
|
||||
{isLoading && <>
|
||||
<li><Skeleton /></li>
|
||||
<li><Skeleton /></li>
|
||||
<li><Skeleton /></li>
|
||||
</>}
|
||||
{
|
||||
candidates?.length === 0 && <li className="p-2">No results</li>
|
||||
}
|
||||
{candidates?.map((tag) => <li key={tag.name}
|
||||
className="hover:bg-accent cursor-pointer p-1 rounded-sm transition-colors
|
||||
focus:outline-none focus:bg-accent focus:text-accent-foreground"
|
||||
tabIndex={-1}
|
||||
onClick={() => onSelect?.(tag.name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onSelect?.(tag.name);
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
const next = e.currentTarget.nextElementSibling as HTMLElement;
|
||||
next?.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
const prev = e.currentTarget.previousElementSibling as HTMLElement;
|
||||
if (prev){
|
||||
prev.focus();
|
||||
}
|
||||
else {
|
||||
onFirstArrowUp();
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
>{tag.name}</li>)}
|
||||
</ul>
|
||||
}
|
||||
|
||||
interface TagInputProps {
|
||||
className?: string;
|
||||
tags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
input: string;
|
||||
onInputChange: (input: string) => void;
|
||||
}
|
||||
|
||||
export default function TagInput({
|
||||
className,
|
||||
tags = [],
|
||||
onTagsChange = () => { },
|
||||
input = "",
|
||||
onInputChange = () => { },
|
||||
}: TagInputProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const setTags = onTagsChange;
|
||||
const setInput = onInputChange;
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [openInfo, setOpenInfo] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||
useOnClickOutside(autocompleteRef, () => {
|
||||
setOpenInfo(null);
|
||||
});
|
||||
useEffect(() => {
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpenInfo(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keyup", listener);
|
||||
return () => {
|
||||
document.removeEventListener("keyup", listener);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <>
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: input exist */}
|
||||
<div className={cn(`flex h-9 w-full rounded-md border border-input bg-transparent
|
||||
px-3 py-1 text-sm shadow-sm transition-colors justify-start items-center pr-0
|
||||
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
|
||||
disabled:cursor-not-allowed disabled:opacity-50`,
|
||||
isFocused && "outline-none ring-1 ring-ring",
|
||||
className
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<ul className="flex gap-1 flex-none">
|
||||
{tags.map((tag) => <li className={cn(
|
||||
tagBadgeVariants({ variant: getTagKind(tag) }),
|
||||
"cursor-pointer"
|
||||
)} key={tag} onPointerDown={() =>{
|
||||
setTags(tags.filter(x=>x!==tag));
|
||||
}}>{tag}</li>)}
|
||||
</ul>
|
||||
<input ref={inputRef} type="text" className="flex-1 border-0 ml-2 focus:border-0 focus:outline-none
|
||||
bg-transparent text-sm" placeholder="Add tag"
|
||||
onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)}
|
||||
value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (input.trim() === "") return;
|
||||
setTags([...tags, input]);
|
||||
setInput("");
|
||||
setOpenInfo(null);
|
||||
}
|
||||
if (e.key === "Backspace" && input === "") {
|
||||
setTags(tags.slice(0, -1));
|
||||
setOpenInfo(null);
|
||||
}
|
||||
if (e.key === ":" || (e.ctrlKey && e.key === " ")) {
|
||||
if (inputRef.current) {
|
||||
const rect = inputRef.current.getBoundingClientRect();
|
||||
setOpenInfo({
|
||||
top: rect.bottom,
|
||||
left: rect.left,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (e.key === "Down" || e.key === "ArrowDown") {
|
||||
if (openInfo && autocompleteRef.current) {
|
||||
const firstChild = autocompleteRef.current.firstElementChild?.firstElementChild as HTMLElement;
|
||||
firstChild?.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{
|
||||
openInfo && <div
|
||||
ref={autocompleteRef}
|
||||
className="absolute z-20 shadow-md bg-popover text-popover-foreground
|
||||
border
|
||||
rounded-sm p-2 w-[200px]"
|
||||
style={{ top: openInfo.top, left: openInfo.left }}
|
||||
>
|
||||
<TagsSelectList search={input} onSelect={(tag) => {
|
||||
setTags([...tags, tag]);
|
||||
setInput("");
|
||||
setOpenInfo(null);
|
||||
}}
|
||||
onFirstArrowUp={() => {
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
tags.length > 0 && <Button variant="ghost" className="flex-none" onClick={() => {
|
||||
setTags([]);
|
||||
setOpenInfo(null);
|
||||
}}>Clear</Button>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
49
packages/client/src/components/layout/layout.tsx
Normal file
49
packages/client/src/components/layout/layout.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "../ui/resizable";
|
||||
import { NavList } from "./nav";
|
||||
|
||||
|
||||
interface LayoutProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const MIN_SIZE_IN_PIXELS = 70;
|
||||
const [minSize, setMinSize] = useState(MIN_SIZE_IN_PIXELS);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const panelGroup = document.querySelector('[data-panel-group-id="main"]');
|
||||
const resizeHandles = document.querySelectorAll(
|
||||
"[data-panel-resize-handle-id]"
|
||||
);
|
||||
if (!panelGroup || !resizeHandles) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
let width = panelGroup?.clientWidth;
|
||||
if (!width) return;
|
||||
width -= [...resizeHandles].reduce((acc, resizeHandle) => acc + resizeHandle.clientWidth, 0);
|
||||
// Minimum size in pixels is a percentage of the PanelGroup's height,
|
||||
// less the (fixed) height of the resize handles.
|
||||
setMinSize((MIN_SIZE_IN_PIXELS / width) * 100);
|
||||
});
|
||||
observer.observe(panelGroup);
|
||||
for (const resizeHandle of resizeHandles) {
|
||||
observer.observe(resizeHandle);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal" id="main">
|
||||
<ResizablePanel minSize={minSize} collapsible maxSize={minSize}>
|
||||
<NavList />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className="z-20" />
|
||||
<ResizablePanel >
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
78
packages/client/src/components/layout/nav.tsx
Normal file
78
packages/client/src/components/layout/nav.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { Link } from "wouter"
|
||||
import { MagnifyingGlassIcon, GearIcon, ActivityLogIcon, ArchiveIcon, PersonIcon } from "@radix-ui/react-icons"
|
||||
import { Button, buttonVariants } from "@/components/ui/button.tsx"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"
|
||||
import { useLogin } from "@/state/user.ts";
|
||||
import { useNavItems } from "./navAtom";
|
||||
import { Separator } from "../ui/separator";
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
to: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function NavItem({
|
||||
icon,
|
||||
to,
|
||||
name
|
||||
}: NavItemProps) {
|
||||
return <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={to}
|
||||
className={buttonVariants({ variant: "ghost" })}
|
||||
>
|
||||
{icon}
|
||||
<span className="sr-only">{name}</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{name}</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
interface NavItemButtonProps {
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
name: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NavItemButton({
|
||||
icon,
|
||||
onClick,
|
||||
name,
|
||||
className
|
||||
}: NavItemButtonProps) {
|
||||
return <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onClick}
|
||||
variant="ghost"
|
||||
className={className}
|
||||
>
|
||||
{icon}
|
||||
<span className="sr-only">{name}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{name}</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
export function NavList() {
|
||||
const loginInfo = useLogin();
|
||||
const navItems = useNavItems();
|
||||
|
||||
return <aside className="h-dvh flex flex-col">
|
||||
<nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1">
|
||||
{navItems && <>{navItems} <Separator/> </>}
|
||||
<NavItem icon={<MagnifyingGlassIcon className="h-5 w-5" />} to="/search" name="Search" />
|
||||
<NavItem icon={<ActivityLogIcon className="h-5 w-5" />} to="/tags" name="Tags" />
|
||||
<NavItem icon={<ArchiveIcon className="h-5 w-5" />} to="/difference" name="Difference" />
|
||||
</nav>
|
||||
<nav className="mt-auto flex flex-col items-center gap-4 px-2 sm:py-5 flex-grow-0">
|
||||
<NavItem icon={<PersonIcon className="h-5 w-5" />} to={ loginInfo ? "/profile" : "/login"} name={ loginInfo ? "Profiles" : "Login"} />
|
||||
<NavItem icon={<GearIcon className="h-5 w-5" />} to="/setting" name="Settings" />
|
||||
</nav>
|
||||
</aside>
|
||||
}
|
23
packages/client/src/components/layout/navAtom.tsx
Normal file
23
packages/client/src/components/layout/navAtom.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { atom, useAtomValue, setAtomValue, getAtomState } from "@/lib/atom";
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
const NavItems = atom<React.ReactNode>("NavItems", null);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useNavItems() {
|
||||
return useAtomValue(NavItems);
|
||||
}
|
||||
|
||||
export function PageNavItem({items, children}:{items: React.ReactNode, children: React.ReactNode}) {
|
||||
useLayoutEffect(() => {
|
||||
const prev = getAtomState(NavItems).value;
|
||||
const setter = setAtomValue(NavItems);
|
||||
setter(items);
|
||||
return () => {
|
||||
setter(prev);
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
36
packages/client/src/components/ui/badge.tsx
Normal file
36
packages/client/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils.ts"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
57
packages/client/src/components/ui/button.tsx
Normal file
57
packages/client/src/components/ui/button.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils.ts"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
76
packages/client/src/components/ui/card.tsx
Normal file
76
packages/client/src/components/ui/card.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils.ts"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
25
packages/client/src/components/ui/input.tsx
Normal file
25
packages/client/src/components/ui/input.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils.ts"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
24
packages/client/src/components/ui/label.tsx
Normal file
24
packages/client/src/components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils.ts"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
31
packages/client/src/components/ui/popover.tsx
Normal file
31
packages/client/src/components/ui/popover.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
42
packages/client/src/components/ui/radio-group.tsx
Normal file
42
packages/client/src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import { CheckIcon } from "@radix-ui/react-icons"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<CheckIcon className="h-3.5 w-3.5 fill-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
43
packages/client/src/components/ui/resizable.tsx
Normal file
43
packages/client/src/components/ui/resizable.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { DragHandleDots2Icon } from "@radix-ui/react-icons"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils.ts"
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<DragHandleDots2Icon className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
29
packages/client/src/components/ui/separator.tsx
Normal file
29
packages/client/src/components/ui/separator.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
15
packages/client/src/components/ui/skeleton.tsx
Normal file
15
packages/client/src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils.ts"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
28
packages/client/src/components/ui/tooltip.tsx
Normal file
28
packages/client/src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils.ts"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
20
packages/client/src/hook/fetcher.ts
Normal file
20
packages/client/src/hook/fetcher.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export const BASE_API_URL = import.meta.env.VITE_API_URL ?? window.location.origin;
|
||||
|
||||
export function makeApiUrl(pathnameAndQueryparam: string) {
|
||||
return new URL(pathnameAndQueryparam, BASE_API_URL).toString();
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public readonly status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetcher(url: string, init?: RequestInit) {
|
||||
const u = makeApiUrl(url);
|
||||
const res = await fetch(u, init);
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, await res.text());
|
||||
}
|
||||
return res.json();
|
||||
}
|
38
packages/client/src/hook/useDifference.ts
Normal file
38
packages/client/src/hook/useDifference.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { fetcher } from "./fetcher";
|
||||
|
||||
type FileDifference = {
|
||||
type: string;
|
||||
value: {
|
||||
type: string;
|
||||
path: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function useDifferenceDoc() {
|
||||
return useSWR<FileDifference[]>("/api/diff/list", fetcher);
|
||||
}
|
||||
|
||||
export async function commit(path: string, type: string) {
|
||||
const data = await fetcher("/api/diff/commit", {
|
||||
method: "POST",
|
||||
body: JSON.stringify([{ path, type }]),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
mutate("/api/diff/list");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function commitAll(type: string) {
|
||||
const data = await fetcher("/api/diff/commitall", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ type }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
mutate("/api/diff/list");
|
||||
return data;
|
||||
}
|
7
packages/client/src/hook/useGalleryDoc.ts
Normal file
7
packages/client/src/hook/useGalleryDoc.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import useSWR from "swr";
|
||||
import type { Document } from "dbtype/api";
|
||||
import { fetcher } from "./fetcher";
|
||||
|
||||
export function useGalleryDoc(id: string) {
|
||||
return useSWR<Document>(`/api/doc/${id}`, fetcher);
|
||||
}
|
62
packages/client/src/hook/useSearchGallery.ts
Normal file
62
packages/client/src/hook/useSearchGallery.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import useSWRInifinite from "swr/infinite";
|
||||
import type { Document } from "dbtype/api";
|
||||
import { fetcher } from "./fetcher";
|
||||
import useSWR from "swr";
|
||||
|
||||
interface SearchParams {
|
||||
word?: string;
|
||||
tags?: string[];
|
||||
limit?: number;
|
||||
cursor?: number;
|
||||
}
|
||||
|
||||
function makeSearchParams({
|
||||
word, tags, limit, cursor,
|
||||
}: SearchParams){
|
||||
const search = new URLSearchParams();
|
||||
if (word) search.set("word", word);
|
||||
if (tags) {
|
||||
for (const tag of tags){
|
||||
search.append("allow_tag", tag);
|
||||
}
|
||||
}
|
||||
if (limit) search.set("limit", limit.toString());
|
||||
if (cursor) search.set("cursor", cursor.toString());
|
||||
return search;
|
||||
}
|
||||
|
||||
export function useSearchGallery(searchParams: SearchParams = {}) {
|
||||
return useSWR<Document[]>(`/api/doc/search?${makeSearchParams(searchParams).toString()}`, fetcher);
|
||||
}
|
||||
|
||||
export function useSearchGalleryInfinite(searchParams: SearchParams = {}) {
|
||||
return useSWRInifinite<
|
||||
{
|
||||
data: Document[];
|
||||
nextCursor: number | null;
|
||||
startCursor: number | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
>((index, previous) => {
|
||||
if (!previous && index > 0) return null;
|
||||
if (previous && !previous.hasMore) return null;
|
||||
const search = makeSearchParams(searchParams)
|
||||
if (index === 0) {
|
||||
return `/api/doc/search?${search.toString()}`;
|
||||
}
|
||||
if (!previous || !previous.data) return null;
|
||||
const last = previous.data[previous.data.length - 1];
|
||||
search.set("cursor", last.id.toString());
|
||||
return `/api/doc/search?${search.toString()}`;
|
||||
}, async (url) => {
|
||||
const limit = searchParams.limit;
|
||||
const res = await fetcher(url);
|
||||
return {
|
||||
data: res,
|
||||
startCursor: res.length === 0 ? null : res[0].id,
|
||||
nextCursor: res.length === 0 ? null : res[res.length - 1].id,
|
||||
hasMore: limit ? res.length === limit : (res.length === 20),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
9
packages/client/src/hook/useTags.ts
Normal file
9
packages/client/src/hook/useTags.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "./fetcher";
|
||||
|
||||
export function useTags() {
|
||||
return useSWR<{
|
||||
name: string;
|
||||
description: string;
|
||||
}[]>("/api/tags", fetcher);
|
||||
}
|
76
packages/client/src/index.css
Normal file
76
packages/client/src/index.css
Normal file
@ -0,0 +1,76 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
70
packages/client/src/lib/atom.ts
Normal file
70
packages/client/src/lib/atom.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
|
||||
interface AtomState<T> {
|
||||
value: T;
|
||||
listeners: Set<() => void>;
|
||||
}
|
||||
interface Atom<T> {
|
||||
key: string;
|
||||
default: T;
|
||||
}
|
||||
|
||||
const atomStateMap = new WeakMap<Atom<unknown>, AtomState<unknown>>();
|
||||
|
||||
export function atom<T>(key: string, defaultVal: T): Atom<T> {
|
||||
return { key, default: defaultVal };
|
||||
}
|
||||
|
||||
export function getAtomState<T>(atom: Atom<T>): AtomState<T> {
|
||||
let atomState = atomStateMap.get(atom);
|
||||
if (!atomState) {
|
||||
atomState = {
|
||||
value: atom.default,
|
||||
listeners: new Set(),
|
||||
};
|
||||
atomStateMap.set(atom, atomState);
|
||||
}
|
||||
return atomState as AtomState<T>;
|
||||
}
|
||||
|
||||
export function useAtom<T>(atom: Atom<T>): [T, (val: T) => void] {
|
||||
const state = getAtomState(atom);
|
||||
const [, setState] = useState(state.value);
|
||||
useEffect(() => {
|
||||
const listener = () => setState(state.value);
|
||||
state.listeners.add(listener);
|
||||
return () => {
|
||||
state.listeners.delete(listener);
|
||||
};
|
||||
}, [state]);
|
||||
return [
|
||||
state.value as T,
|
||||
(val: T) => {
|
||||
state.value = val;
|
||||
// biome-ignore lint/complexity/noForEach: forEach is used to call each listener
|
||||
state.listeners.forEach((listener) => listener());
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function useAtomValue<T>(atom: Atom<T>): T {
|
||||
const state = getAtomState(atom);
|
||||
const update = useReducer((x) => x + 1, 0)[1];
|
||||
useEffect(() => {
|
||||
const listener = () => update();
|
||||
state.listeners.add(listener);
|
||||
return () => {
|
||||
state.listeners.delete(listener);
|
||||
};
|
||||
}, [state, update]);
|
||||
return state.value;
|
||||
}
|
||||
|
||||
export function setAtomValue<T>(atom: Atom<T>): (val: T) => void {
|
||||
const state = getAtomState(atom);
|
||||
return (val: T) => {
|
||||
state.value = val;
|
||||
// biome-ignore lint/complexity/noForEach: forEach is used to call each listener
|
||||
state.listeners.forEach((listener) => listener());
|
||||
};
|
||||
}
|
33
packages/client/src/lib/classifyTags.tsx
Normal file
33
packages/client/src/lib/classifyTags.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
interface TagClassifyResult {
|
||||
artist: string[];
|
||||
group: string[];
|
||||
series: string[];
|
||||
type: string[];
|
||||
character: string[];
|
||||
rest: string[];
|
||||
}
|
||||
|
||||
export function classifyTags(tags: string[]): TagClassifyResult {
|
||||
const result = {
|
||||
artist: [],
|
||||
group: [],
|
||||
series: [],
|
||||
type: [],
|
||||
character: [],
|
||||
rest: [],
|
||||
} as TagClassifyResult;
|
||||
const tagKind = new Set(["artist", "group", "series", "type", "character"]);
|
||||
for (const tag of tags) {
|
||||
const split = tag.split(":");
|
||||
if (split.length !== 2) {
|
||||
continue;
|
||||
}
|
||||
const [prefix, name] = split;
|
||||
if (tagKind.has(prefix)) {
|
||||
result[prefix as keyof TagClassifyResult].push(name);
|
||||
} else {
|
||||
result.rest.push(tag);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
6
packages/client/src/lib/utils.ts
Normal file
6
packages/client/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
11
packages/client/src/main.tsx
Normal file
11
packages/client/src/main.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
9
packages/client/src/page/404.tsx
Normal file
9
packages/client/src/page/404.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
export const NotFoundPage = () => {
|
||||
return (<div className="flex items-center justify-center flex-col box-border h-screen space-y-2">
|
||||
<h2 className="text-6xl">404 Not Found</h2>
|
||||
<p>찾을 수 없음</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFoundPage;
|
82
packages/client/src/page/contentInfoPage.tsx
Normal file
82
packages/client/src/page/contentInfoPage.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useGalleryDoc } from "../hook/useGalleryDoc.ts";
|
||||
import TagBadge from "@/components/gallery/TagBadge";
|
||||
import StyledLink from "@/components/gallery/StyledLink";
|
||||
import { Link } from "wouter";
|
||||
import { classifyTags } from "../lib/classifyTags.tsx";
|
||||
import { DescTagItem, DescItem } from "../components/gallery/DescItem.tsx";
|
||||
|
||||
export interface ContentInfoPageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ContentInfoPage({ params }: ContentInfoPageProps) {
|
||||
const { data, error, isLoading } = useGalleryDoc(params.id);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-4">Loading...</div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-4">Error: {String(error)}</div>
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="p-4">Not found</div>
|
||||
}
|
||||
|
||||
const tags = data?.tags ?? [];
|
||||
const classifiedTags = classifyTags(tags);
|
||||
|
||||
const contentLocation = `/doc/${params.id}/reader`;
|
||||
|
||||
return (
|
||||
<div className="p-4 h-dvh overflow-auto">
|
||||
<Link to={contentLocation}>
|
||||
<div className="m-auto h-[400px] mb-4 flex justify-center items-center flex-none bg-[#272733]
|
||||
rounded-xl shadow-lg overflow-hidden">
|
||||
<img
|
||||
className="max-w-full max-h-full object-cover object-center"
|
||||
src={`/api/doc/${data.id}/comic/thumbnail`}
|
||||
alt={data.title} />
|
||||
</div>
|
||||
</Link>
|
||||
<Card className="flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<StyledLink to={contentLocation}>
|
||||
{data.title}
|
||||
</StyledLink>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<StyledLink to={`/search?allow_tag=type:${classifiedTags.type[0] ?? ""}`} className="text-sm">
|
||||
{classifiedTags.type[0] ?? "N/A"}
|
||||
</StyledLink>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto_fill_300px)]">
|
||||
<DescTagItem name="artist" items={classifiedTags.artist} />
|
||||
<DescTagItem name="group" items={classifiedTags.group} />
|
||||
<DescTagItem name="series" items={classifiedTags.series} />
|
||||
<DescTagItem name="character" items={classifiedTags.character} />
|
||||
<DescItem name="Created At">{new Date(data.created_at).toLocaleString()}</DescItem>
|
||||
<DescItem name="Modified At">{new Date(data.modified_at).toLocaleString()}</DescItem>
|
||||
<DescItem name="Path">{`${data.basepath}/${data.filename}`}</DescItem>
|
||||
<DescItem name="Page Count">{JSON.stringify(data.additional)}</DescItem>
|
||||
</div>
|
||||
<div className="grid mt-4">
|
||||
<span className="text-muted-foreground text-sm">Tags</span>
|
||||
<ul className="mt-2 flex flex-wrap gap-1">
|
||||
{classifiedTags.rest.map((tag) => <TagBadge key={tag} tagname={tag} />)}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContentInfoPage;
|
62
packages/client/src/page/differencePage.tsx
Normal file
62
packages/client/src/page/differencePage.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDifferenceDoc, commit, commitAll } from "@/hook/useDifference";
|
||||
import { useLogin } from "@/state/user";
|
||||
import { Fragment } from "react/jsx-runtime";
|
||||
|
||||
export function DifferencePage() {
|
||||
const { data, isLoading, error } = useDifferenceDoc();
|
||||
const userInfo = useLogin();
|
||||
|
||||
if (!userInfo) {
|
||||
return <div className="p-4">
|
||||
<h2 className="text-3xl">
|
||||
Not logged in
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {String(error)}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Card>
|
||||
<CardHeader className="relative">
|
||||
<Button className="absolute right-2 top-8" variant="ghost"
|
||||
onClick={() => {commitAll("comic")}}
|
||||
>Commit All</Button>
|
||||
<CardTitle className="text-2xl">Difference</CardTitle>
|
||||
<CardDescription>Scanned Files List</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Separator decorative />
|
||||
{isLoading && <div>Loading...</div>}
|
||||
{data?.map((c) => {
|
||||
const x = c.value;
|
||||
return (
|
||||
<Fragment key={c.type}>
|
||||
{x.map((y) => (
|
||||
<div key={y.path} className="flex items-center mt-2">
|
||||
<p
|
||||
className="flex-1 text-sm text-wrap">{y.path}</p>
|
||||
<Button
|
||||
className="flex-none ml-2"
|
||||
variant="outline"
|
||||
onClick={() => {commit(y.path, y.type)}}>
|
||||
Commit
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DifferencePage;
|
141
packages/client/src/page/galleryPage.tsx
Normal file
141
packages/client/src/page/galleryPage.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { GalleryCard } from "@/components/gallery/GalleryCard.tsx";
|
||||
import TagBadge from "@/components/gallery/TagBadge.tsx";
|
||||
import { useSearchGalleryInfinite } from "../hook/useSearchGallery.ts";
|
||||
import { Spinner } from "../components/Spinner.tsx";
|
||||
import TagInput from "@/components/gallery/TagInput.tsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
export default function Gallery() {
|
||||
const search = useSearch();
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const word = searchParams.get("word") ?? undefined;
|
||||
const tags = searchParams.getAll("allow_tag") ?? undefined;
|
||||
const limit = searchParams.get("limit");
|
||||
const cursor = searchParams.get("cursor");
|
||||
const { data, error, isLoading, size, setSize } = useSearchGalleryInfinite({
|
||||
word, tags,
|
||||
limit: limit ? Number.parseInt(limit) : undefined,
|
||||
cursor: cursor ? Number.parseInt(cursor) : undefined
|
||||
});
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: size,
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
getScrollElement: () => parentRef.current!,
|
||||
estimateSize: (index) => {
|
||||
if (!data) return 8;
|
||||
const docs = data?.[index];
|
||||
if (!docs) return 8;
|
||||
return docs.data.length * (200 + 8) + 37 + 8;
|
||||
},
|
||||
overscan: 1,
|
||||
});
|
||||
|
||||
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
useEffect(() => {
|
||||
const lastItems = virtualItems.slice(-1);
|
||||
if (lastItems.some(x => x.index >= size - 1)) {
|
||||
const last = lastItems[0];
|
||||
const docs = data?.[last.index];
|
||||
if (docs?.hasMore) {
|
||||
setSize(size + 1);
|
||||
}
|
||||
}
|
||||
}, [virtualItems, setSize, size, data]);
|
||||
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
}, [virtualizer, data]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-4">Loading...</div>
|
||||
}
|
||||
if (error) {
|
||||
return <div className="p-4">Error: {String(error)}</div>
|
||||
}
|
||||
if (!data) {
|
||||
return <div className="p-4">No data</div>
|
||||
}
|
||||
|
||||
|
||||
const isLoadingMore = data && size > 0 && (data[size - 1] === undefined);
|
||||
return (<div className="p-4 grid gap-2 overflow-auto h-dvh items-start content-start" ref={parentRef}>
|
||||
<Search />
|
||||
{(word || tags) &&
|
||||
<div className="bg-primary rounded-full p-1 sticky top-0 shadow-md z-10">
|
||||
{word && <span className="text-primary-foreground ml-4">Search: {word}</span>}
|
||||
{tags && <span className="text-primary-foreground ml-4">Tags: <ul className="inline-flex gap-1">{
|
||||
tags.map(x => <TagBadge tagname={x} key={x} />)}
|
||||
</ul></span>}
|
||||
</div>
|
||||
}
|
||||
{data?.length === 0 && <div className="p-4 text-3xl">No results</div>}
|
||||
<div className="w-full relative"
|
||||
style={{ height: virtualizer.getTotalSize() }}>
|
||||
{// TODO: date based grouping
|
||||
virtualItems.map((item) => {
|
||||
const isLoaderRow = item.index === size - 1 && isLoadingMore;
|
||||
if (isLoaderRow) {
|
||||
return <div key={item.index} className="w-full flex justify-center top-0 left-0 absolute"
|
||||
style={{
|
||||
height: `${item.size}px`,
|
||||
transform: `translateY(${item.start}px)`
|
||||
}}>
|
||||
<Spinner />
|
||||
</div>;
|
||||
}
|
||||
const docs = data[item.index];
|
||||
if (!docs) return null;
|
||||
return <div className="w-full grid gap-2 content-start top-0 left-0 absolute mb-2" key={item.index}
|
||||
style={{
|
||||
height: `${item.size}px`,
|
||||
transform: `translateY(${item.start}px)`
|
||||
}}>
|
||||
{docs.startCursor && <div>
|
||||
<h3 className="text-3xl">Start with {docs.startCursor}</h3>
|
||||
<Separator />
|
||||
</div>}
|
||||
{docs?.data?.map((x) => {
|
||||
return (
|
||||
<GalleryCard doc={x} key={x.id} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Search() {
|
||||
const search = useSearch();
|
||||
const [, navigate] = useLocation();
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const [tags, setTags] = useState(searchParams.get("allow_tag")?.split(",") ?? []);
|
||||
const [word, setWord] = useState(searchParams.get("word") ?? "");
|
||||
return <div className="flex space-x-2">
|
||||
<TagInput className="flex-1" input={word} onInputChange={setWord}
|
||||
tags={tags} onTagsChange={setTags}
|
||||
/>
|
||||
<Button className="flex-none" onClick={() => {
|
||||
const params = new URLSearchParams();
|
||||
if (tags.length > 0) {
|
||||
for (const tag of tags) {
|
||||
params.append("allow_tag", tag);
|
||||
}
|
||||
}
|
||||
if (word) {
|
||||
params.set("word", word);
|
||||
}
|
||||
navigate(`/search?${params.toString()}`);
|
||||
}}>Search</Button>
|
||||
</div>;
|
||||
}
|
||||
|
58
packages/client/src/page/loginPage.tsx
Normal file
58
packages/client/src/page/loginPage.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { doLogin } from "@/state/user.ts";
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
export function LoginForm() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email below to login to your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" type="text" placeholder="username" required value={username} onChange={e=> setUsername(e.target.value)}/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" required value={password} onChange={e=> setPassword(e.target.value)}/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" onClick={()=>{
|
||||
doLogin({
|
||||
username,
|
||||
password,
|
||||
}).then((r)=>{
|
||||
if (typeof r === "string") {
|
||||
alert(r);
|
||||
} else {
|
||||
setLocation("/");
|
||||
}
|
||||
})
|
||||
}}>Sign in</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<LoginForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage;
|
34
packages/client/src/page/profilesPage.tsx
Normal file
34
packages/client/src/page/profilesPage.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useLogin } from "@/state/user";
|
||||
import { Redirect } from "wouter";
|
||||
|
||||
export function ProfilePage() {
|
||||
const userInfo = useLogin();
|
||||
if (!userInfo) {
|
||||
console.error("User session expired. Redirecting to login page.");
|
||||
return <Redirect to="/login" />;
|
||||
}
|
||||
// TODO: Add a logout button
|
||||
// TODO: Add a change password button
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Profile</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid">
|
||||
<span className="text-muted-foreground text-sm">Username</span>
|
||||
<span className="text-primary text-lg">{userInfo.username}</span>
|
||||
</div>
|
||||
<div className="grid">
|
||||
<span className="text-muted-foreground text-sm">Permission</span>
|
||||
<span className="text-primary text-lg">{userInfo.permission.length > 1 ? userInfo.permission.join(",") : "N/A"}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfilePage;
|
166
packages/client/src/page/reader/comicPage.tsx
Normal file
166
packages/client/src/page/reader/comicPage.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { NavItem, NavItemButton } from "@/components/layout/nav";
|
||||
import { PageNavItem } from "@/components/layout/navAtom";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useGalleryDoc } from "@/hook/useGalleryDoc.ts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EnterFullScreenIcon, ExitFullScreenIcon, ExitIcon } from "@radix-ui/react-icons";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import type { Document } from "dbtype/api";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface ComicPageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
function ComicViewer({
|
||||
doc,
|
||||
totalPage,
|
||||
curPage,
|
||||
onChangePage: setCurPage,
|
||||
}: {
|
||||
doc: Document;
|
||||
totalPage: number;
|
||||
curPage: number;
|
||||
onChangePage: (page: number) => void;
|
||||
}) {
|
||||
const [fade, setFade] = useState(false);
|
||||
const PageDown = useCallback((step: number) => setCurPage(Math.max(curPage - step, 0)), [curPage, setCurPage]);
|
||||
const PageUp = useCallback((step: number) => setCurPage(Math.min(curPage + step, totalPage - 1)), [curPage, setCurPage, totalPage]);
|
||||
const currentImageRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
if (e.code === "ArrowLeft") {
|
||||
PageDown(step);
|
||||
} else if (e.code === "ArrowRight") {
|
||||
PageUp(step);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
};
|
||||
}, [PageDown, PageUp]);
|
||||
|
||||
useEffect(() => {
|
||||
if(currentImageRef.current){
|
||||
if (curPage < 0 || curPage >= totalPage) {
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.src = `/api/doc/${doc.id}/comic/${curPage}`;
|
||||
if (img.complete) {
|
||||
currentImageRef.current.src = img.src;
|
||||
setFade(false);
|
||||
return;
|
||||
}
|
||||
setFade(true);
|
||||
const listener = () => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
const currentImage = currentImageRef.current!;
|
||||
currentImage.src = img.src;
|
||||
setFade(false);
|
||||
};
|
||||
img.addEventListener("load", listener);
|
||||
return () => {
|
||||
img.removeEventListener("load", listener);
|
||||
// abort loading
|
||||
img.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=;';
|
||||
// TODO: use web worker to abort loading image in the future
|
||||
};
|
||||
}
|
||||
}, [curPage, doc.id, totalPage]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden w-full h-full relative">
|
||||
<div className="absolute left-0 w-1/2 h-full z-10" onMouseDown={() => PageDown(1)} />
|
||||
<img
|
||||
ref={currentImageRef}
|
||||
className={cn("max-w-full max-h-full top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 absolute",
|
||||
fade ? "opacity-70 transition-opacity duration-300 ease-in-out" : "opacity-100"
|
||||
)}
|
||||
alt="main content"/>
|
||||
<div className="absolute right-0 w-1/2 h-full z-10" onMouseDown={() => PageUp(1)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clip(val: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, val));
|
||||
}
|
||||
|
||||
function useFullScreen() {
|
||||
const ref = useRef<HTMLElement>(document.documentElement);
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
|
||||
const toggleFullScreen = useCallback(() => {
|
||||
if (isFullScreen) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
}, [isFullScreen]);
|
||||
|
||||
useEventListener("fullscreenchange", () => {
|
||||
setIsFullScreen(!!document.fullscreenElement);
|
||||
}, ref);
|
||||
return { isFullScreen, toggleFullScreen };
|
||||
}
|
||||
|
||||
export default function ComicPage({
|
||||
params
|
||||
}: ComicPageProps) {
|
||||
const { data, error, isLoading } = useGalleryDoc(params.id);
|
||||
const [curPage, setCurPage] = useState(0);
|
||||
const { isFullScreen, toggleFullScreen } = useFullScreen();
|
||||
if (isLoading) {
|
||||
// TODO: Add a loading spinner
|
||||
return <div className="p-4">
|
||||
Loading...
|
||||
</div>
|
||||
}
|
||||
if (error) {
|
||||
return <div className="p-4">Error: {String(error)}</div>
|
||||
}
|
||||
if (!data) {
|
||||
return <div className="p-4">Not found</div>
|
||||
}
|
||||
|
||||
if (data.content_type !== "comic") {
|
||||
return <div className="p-4">Not a comic</div>
|
||||
}
|
||||
if (!("page" in data.additional)) {
|
||||
console.error(`invalid content : page read fail : ${JSON.stringify(data.additional)}`);
|
||||
return <div className="p-4">Error. DB error. page restriction</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<PageNavItem items={<>
|
||||
<NavItem to={`/doc/${params.id}`} name="Back" icon={<ExitIcon />}/>
|
||||
<NavItemButton name={isFullScreen ? "Exit Fullscreen" : "Enter Fullscreen"} icon={isFullScreen ? <ExitFullScreenIcon/> : <EnterFullScreenIcon/>} onClick={()=>{
|
||||
toggleFullScreen();
|
||||
}} />
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<span className="text-sm text-ellipsis" >{curPage + 1}/{data.additional.page as number}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-28">
|
||||
<Input type="number" value={curPage + 1} onChange={(e) =>
|
||||
setCurPage(clip(Number.parseInt(e.target.value) - 1,
|
||||
0,
|
||||
(data.additional.page as number) - 1))} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>}>
|
||||
<ComicViewer
|
||||
curPage={curPage}
|
||||
onChangePage={setCurPage}
|
||||
doc={data}
|
||||
totalPage={data.additional.page as number} />
|
||||
</PageNavItem>
|
||||
)
|
||||
}
|
92
packages/client/src/page/settingPage.tsx
Normal file
92
packages/client/src/page/settingPage.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { type TernaryDarkMode, useTernaryDarkMode, useMediaQuery } from "usehooks-ts";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
function LightModeView() {
|
||||
return <div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
|
||||
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
|
||||
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DarkModeView() {
|
||||
return <div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
|
||||
<div className="space-y-2 rounded-sm bg-slate-950 p-2">
|
||||
<div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
||||
<div className="h-2 w-[80px] rounded-lg bg-slate-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-slate-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-slate-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function SettingPage() {
|
||||
const { setTernaryDarkMode, ternaryDarkMode } = useTernaryDarkMode();
|
||||
const isSystemDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg">Appearance</h3>
|
||||
<span className="text-muted-foreground text-sm">Dark mode</span>
|
||||
</div>
|
||||
<RadioGroup value={ternaryDarkMode} onValueChange={(v) => setTernaryDarkMode(v as TernaryDarkMode)}
|
||||
className="flex space-x-2 items-center"
|
||||
>
|
||||
<RadioGroupItem id="dark" value="dark" className="sr-only" />
|
||||
<Label htmlFor="dark">
|
||||
<div className="grid place-items-center">
|
||||
<DarkModeView />
|
||||
<span>Dark Mode</span>
|
||||
</div>
|
||||
</Label>
|
||||
<RadioGroupItem id="light" value="light" className="sr-only" />
|
||||
<Label htmlFor="light">
|
||||
<div className="grid place-items-center">
|
||||
<LightModeView />
|
||||
<span>Light Mode</span>
|
||||
</div>
|
||||
</Label>
|
||||
<RadioGroupItem id="system" value="system" className="sr-only" />
|
||||
<Label htmlFor="system">
|
||||
<div className="grid place-items-center">
|
||||
{isSystemDarkMode ? <DarkModeView /> : <LightModeView />}
|
||||
<span>System Mode</span>
|
||||
</div>
|
||||
</Label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingPage;
|
116
packages/client/src/state/user.ts
Normal file
116
packages/client/src/state/user.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { atom, useAtomValue, setAtomValue } from "../lib/atom.ts";
|
||||
import { makeApiUrl } from "../hook/fetcher.ts";
|
||||
|
||||
type LoginLocalStorage = {
|
||||
username: string;
|
||||
permission: string[];
|
||||
accessExpired: number;
|
||||
};
|
||||
|
||||
let localObj: LoginLocalStorage | null = null;
|
||||
function getUserSessions() {
|
||||
if (localObj === null) {
|
||||
const storagestr = localStorage.getItem("UserLoginContext") as string | null;
|
||||
const storage = storagestr !== null ? (JSON.parse(storagestr) as LoginLocalStorage | null) : null;
|
||||
localObj = storage;
|
||||
}
|
||||
if (localObj !== null && localObj.accessExpired > Math.floor(Date.now() / 1000)) {
|
||||
return {
|
||||
username: localObj.username,
|
||||
permission: localObj.permission,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
const u = makeApiUrl("/api/user/refresh");
|
||||
const res = await fetch(u, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.status !== 200) throw new Error("Maybe Network Error");
|
||||
const r = (await res.json()) as LoginLocalStorage & { refresh: boolean };
|
||||
if (r.refresh) {
|
||||
localObj = {
|
||||
...r
|
||||
};
|
||||
} else {
|
||||
localObj = {
|
||||
accessExpired: 0,
|
||||
username: "",
|
||||
permission: r.permission,
|
||||
};
|
||||
}
|
||||
localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
||||
return {
|
||||
username: r.username,
|
||||
permission: r.permission,
|
||||
};
|
||||
}
|
||||
|
||||
export const doLogout = async () => {
|
||||
const u = makeApiUrl("/api/user/logout");
|
||||
const req = await fetch(u, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const setVal = setAtomValue(userLoginStateAtom);
|
||||
try {
|
||||
const res = await req.json();
|
||||
localObj = {
|
||||
accessExpired: 0,
|
||||
username: "",
|
||||
permission: res.permission,
|
||||
};
|
||||
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
||||
setVal(localObj);
|
||||
return {
|
||||
username: localObj.username,
|
||||
permission: localObj.permission,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Server Error ${error}`);
|
||||
return {
|
||||
username: "",
|
||||
permission: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
export const doLogin = async (userLoginInfo: {
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<string | LoginLocalStorage> => {
|
||||
const u = makeApiUrl("/api/user/login");
|
||||
const res = await fetch(u, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(userLoginInfo),
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
});
|
||||
const b = await res.json();
|
||||
if (res.status !== 200) {
|
||||
return b.detail as string;
|
||||
}
|
||||
const setVal = setAtomValue(userLoginStateAtom);
|
||||
localObj = b;
|
||||
setVal(b);
|
||||
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
||||
return b;
|
||||
};
|
||||
|
||||
|
||||
export async function getInitialValue() {
|
||||
const user = getUserSessions();
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
return refresh();
|
||||
}
|
||||
|
||||
export const userLoginStateAtom = atom("userLoginState", getUserSessions());
|
||||
|
||||
export function useLogin() {
|
||||
const val = useAtomValue(userLoginStateAtom);
|
||||
return val;
|
||||
}
|
1
packages/client/src/vite-env.d.ts
vendored
Normal file
1
packages/client/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
77
packages/client/tailwind.config.js
Normal file
77
packages/client/tailwind.config.js
Normal file
@ -0,0 +1,77 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
30
packages/client/tsconfig.json
Normal file
30
packages/client/tsconfig.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
11
packages/client/tsconfig.node.json
Normal file
11
packages/client/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
20
packages/client/vite.config.ts
Normal file
20
packages/client/vite.config.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import path from 'node:path'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': env.API_BASE_URL ?? 'http://localhost:8000',
|
||||
}
|
||||
}
|
||||
}})
|
53
packages/dbtype/api.ts
Normal file
53
packages/dbtype/api.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import type { JSONMap } from './jsonmap';
|
||||
|
||||
export interface DocumentBody {
|
||||
title: string;
|
||||
content_type: string;
|
||||
basepath: string;
|
||||
filename: string;
|
||||
modified_at: number;
|
||||
content_hash: string | null;
|
||||
additional: JSONMap;
|
||||
tags: string[]; // eager loading
|
||||
}
|
||||
|
||||
export interface Document extends DocumentBody {
|
||||
readonly id: number;
|
||||
readonly created_at: number;
|
||||
readonly deleted_at: number | null;
|
||||
}
|
||||
|
||||
export type QueryListOption = {
|
||||
/**
|
||||
* search word
|
||||
*/
|
||||
word?: string;
|
||||
allow_tag?: string[];
|
||||
/**
|
||||
* limit of list
|
||||
* @default 20
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* use offset if true, otherwise
|
||||
* @default false
|
||||
*/
|
||||
use_offset?: boolean;
|
||||
/**
|
||||
* cursor of documents
|
||||
*/
|
||||
cursor?: number;
|
||||
/**
|
||||
* offset of documents
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* tag eager loading
|
||||
* @default true
|
||||
*/
|
||||
eager_loading?: boolean;
|
||||
/**
|
||||
* content type
|
||||
*/
|
||||
content_type?: string;
|
||||
};
|
18
packages/dbtype/package.json
Normal file
18
packages/dbtype/package.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "dbtype",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.9",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"kysely": "^0.27.3",
|
||||
"kysely-codegen": "^0.14.1"
|
||||
}
|
||||
}
|
53
packages/dbtype/types.ts
Normal file
53
packages/dbtype/types.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import type { ColumnType } from "kysely";
|
||||
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
|
||||
export interface DocTagRelation {
|
||||
doc_id: number;
|
||||
tag_name: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
additional: string | null;
|
||||
basepath: string;
|
||||
content_hash: string | null;
|
||||
content_type: string;
|
||||
created_at: number;
|
||||
deleted_at: number | null;
|
||||
filename: string;
|
||||
id: Generated<number>;
|
||||
modified_at: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Permissions {
|
||||
name: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface SchemaMigration {
|
||||
dirty: number | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export interface Tags {
|
||||
description: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Users {
|
||||
password_hash: string;
|
||||
password_salt: string;
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
doc_tag_relation: DocTagRelation;
|
||||
document: Document;
|
||||
permissions: Permissions;
|
||||
schema_migration: SchemaMigration;
|
||||
tags: Tags;
|
||||
users: Users;
|
||||
}
|
7
packages/server/app.ts
Normal file
7
packages/server/app.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { create_server } from "./src/server";
|
||||
|
||||
create_server().then((server) => {
|
||||
server.start_server();
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
50
packages/server/gen_conf_schema.ts
Normal file
50
packages/server/gen_conf_schema.ts
Normal file
@ -0,0 +1,50 @@
|
||||
// import { promises } from "fs";
|
||||
// const { readdir, writeFile } = promises;
|
||||
// import { dirname, join } from "path";
|
||||
// import { createGenerator } from "ts-json-schema-generator";
|
||||
|
||||
// async function genSchema(path: string, typename: string) {
|
||||
// const gen = createGenerator({
|
||||
// path: path,
|
||||
// type: typename,
|
||||
// tsconfig: "tsconfig.json",
|
||||
// });
|
||||
// const schema = gen.createSchema(typename);
|
||||
// if (schema.definitions != undefined) {
|
||||
// const definitions = schema.definitions;
|
||||
// const definition = definitions[typename];
|
||||
// if (typeof definition == "object") {
|
||||
// let property = definition.properties;
|
||||
// if (property) {
|
||||
// property["$schema"] = {
|
||||
// type: "string",
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// const text = JSON.stringify(schema);
|
||||
// await writeFile(join(dirname(path), `${typename}.schema.json`), text);
|
||||
// }
|
||||
// function capitalize(s: string) {
|
||||
// return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
// }
|
||||
// async function setToALL(path: string) {
|
||||
// console.log(`scan ${path}`);
|
||||
// const direntry = await readdir(path, { withFileTypes: true });
|
||||
// const works = direntry
|
||||
// .filter((x) => x.isFile() && x.name.endsWith("Config.ts"))
|
||||
// .map((x) => {
|
||||
// const name = x.name;
|
||||
// const m = /(.+)\.ts/.exec(name);
|
||||
// if (m !== null) {
|
||||
// const typename = m[1];
|
||||
// return genSchema(join(path, typename), capitalize(typename));
|
||||
// }
|
||||
// });
|
||||
// await Promise.all(works);
|
||||
// const subdir = direntry.filter((x) => x.isDirectory()).map((x) => x.name);
|
||||
// for (const x of subdir) {
|
||||
// await setToALL(join(path, x));
|
||||
// }
|
||||
// }
|
||||
// setToALL("src");
|
56
packages/server/migrations/initial.ts
Normal file
56
packages/server/migrations/initial.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
export async function up(knex: Knex) {
|
||||
await knex.schema.createTable("schema_migration", (b) => {
|
||||
b.string("version");
|
||||
b.boolean("dirty");
|
||||
});
|
||||
|
||||
await knex.schema.createTable("users", (b) => {
|
||||
b.string("username").primary().comment("user's login id");
|
||||
b.string("password_hash", 64).notNullable();
|
||||
b.string("password_salt", 64).notNullable();
|
||||
});
|
||||
await knex.schema.createTable("document", (b) => {
|
||||
b.increments("id").primary();
|
||||
b.string("title").notNullable();
|
||||
b.string("content_type", 16).notNullable();
|
||||
b.string("basepath", 256).notNullable().comment("directory path for resource");
|
||||
b.string("filename", 256).notNullable().comment("filename");
|
||||
b.string("content_hash").nullable();
|
||||
b.json("additional").nullable();
|
||||
b.integer("created_at").notNullable();
|
||||
b.integer("modified_at").notNullable();
|
||||
b.integer("deleted_at");
|
||||
b.index("content_type", "content_type_index");
|
||||
});
|
||||
await knex.schema.createTable("tags", (b) => {
|
||||
b.string("name").primary();
|
||||
b.text("description");
|
||||
});
|
||||
await knex.schema.createTable("doc_tag_relation", (b) => {
|
||||
b.integer("doc_id").unsigned().notNullable();
|
||||
b.string("tag_name").notNullable();
|
||||
b.foreign("doc_id").references("document.id");
|
||||
b.foreign("tag_name").references("tags.name");
|
||||
b.primary(["doc_id", "tag_name"]);
|
||||
});
|
||||
await knex.schema.createTable("permissions", (b) => {
|
||||
b.string("username").notNullable();
|
||||
b.string("name").notNullable();
|
||||
b.primary(["username", "name"]);
|
||||
b.foreign("username").references("users.username");
|
||||
});
|
||||
// create admin account.
|
||||
await knex
|
||||
.insert({
|
||||
username: "admin",
|
||||
password_hash: "unchecked",
|
||||
password_salt: "unchecked",
|
||||
})
|
||||
.into("users");
|
||||
}
|
||||
|
||||
export async function down(knex: Knex) {
|
||||
throw new Error("Downward migrations are not supported. Restore from backup.");
|
||||
}
|
42
packages/server/package.json
Normal file
42
packages/server/package.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "followed",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "build/app.js",
|
||||
"scripts": {
|
||||
"compile": "swc src --out-dir compile",
|
||||
"dev": "nodemon -r @swc-node/register --enable-source-maps --exec node app.ts",
|
||||
"start": "node compile/app.js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "^2.7.40",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"chokidar": "^3.6.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"koa": "^2.15.2",
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"koa-compose": "^4.1.0",
|
||||
"koa-router": "^12.0.1",
|
||||
"kysely": "^0.27.3",
|
||||
"natural-orderby": "^2.0.3",
|
||||
"tiny-async-pool": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc-node/register": "^1.9.0",
|
||||
"@swc/cli": "^0.3.10",
|
||||
"@swc/core": "^1.4.11",
|
||||
"@types/better-sqlite3": "^7.6.9",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/koa": "^2.15.0",
|
||||
"@types/koa-bodyparser": "^4.3.12",
|
||||
"@types/koa-compose": "^3.2.8",
|
||||
"@types/koa-router": "^7.4.8",
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/tiny-async-pool": "^1.0.5",
|
||||
"dbtype": "workspace:^",
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
7
packages/server/preload.ts
Normal file
7
packages/server/preload.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
// contextBridge.exposeInMainWorld("electron", {
|
||||
// passwordReset: async (username: string, toPw: string) => {
|
||||
// return await ipcRenderer.invoke("reset_password", username, toPw);
|
||||
// },
|
||||
// });
|
51
packages/server/src/SettingConfig.schema.json
Normal file
51
packages/server/src/SettingConfig.schema.json
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$ref": "#/definitions/SettingConfig",
|
||||
"definitions": {
|
||||
"SettingConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"localmode": {
|
||||
"type": "boolean",
|
||||
"description": "if true, server will bind on '127.0.0.1' rather than '0.0.0.0'"
|
||||
},
|
||||
"guest": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Permission"
|
||||
},
|
||||
"description": "guest permission"
|
||||
},
|
||||
"jwt_secretkey": {
|
||||
"type": "string",
|
||||
"description": "JWT secret key. if you change its value, all access tokens are invalidated."
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "the port which running server is binding on."
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["development", "production"]
|
||||
},
|
||||
"cli": {
|
||||
"type": "boolean",
|
||||
"description": "if true, do not show 'electron' window and show terminal only."
|
||||
},
|
||||
"forbid_remote_admin_login": {
|
||||
"type": "boolean",
|
||||
"description": "forbid to login admin from remote client. but, it do not invalidate access token. \r if you want to invalidate access token, change 'jwt_secretkey'."
|
||||
},
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["localmode", "guest", "jwt_secretkey", "port", "mode", "cli", "forbid_remote_admin_login"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Permission": {
|
||||
"type": "string",
|
||||
"enum": ["ModifyTag", "QueryContent", "ModifyTagDesc"]
|
||||
}
|
||||
}
|
||||
}
|
80
packages/server/src/SettingConfig.ts
Normal file
80
packages/server/src/SettingConfig.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import type { Permission } from "./permission/permission";
|
||||
|
||||
export interface SettingConfig {
|
||||
/**
|
||||
* if true, server will bind on '127.0.0.1' rather than '0.0.0.0'
|
||||
*/
|
||||
localmode: boolean;
|
||||
/**
|
||||
* secure only
|
||||
*/
|
||||
secure: boolean;
|
||||
|
||||
/**
|
||||
* guest permission
|
||||
*/
|
||||
guest: Permission[];
|
||||
/**
|
||||
* JWT secret key. if you change its value, all access tokens are invalidated.
|
||||
*/
|
||||
jwt_secretkey: string;
|
||||
/**
|
||||
* the port which running server is binding on.
|
||||
*/
|
||||
port: number;
|
||||
|
||||
mode: "development" | "production";
|
||||
/**
|
||||
* if true, do not show 'electron' window and show terminal only.
|
||||
*/
|
||||
cli: boolean;
|
||||
/** forbid to login admin from remote client. but, it do not invalidate access token.
|
||||
* if you want to invalidate access token, change 'jwt_secretkey'. */
|
||||
forbid_remote_admin_login: boolean;
|
||||
}
|
||||
const default_setting: SettingConfig = {
|
||||
localmode: true,
|
||||
secure: true,
|
||||
guest: [],
|
||||
jwt_secretkey: "itsRandom",
|
||||
port: 8080,
|
||||
mode: "production",
|
||||
cli: false,
|
||||
forbid_remote_admin_login: true,
|
||||
};
|
||||
let setting: null | SettingConfig = null;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
const setEmptyToDefault = (target: any, default_table: SettingConfig) => {
|
||||
let diff_occur = false;
|
||||
for (const key in default_table) {
|
||||
if (key === undefined || key in target) {
|
||||
continue;
|
||||
}
|
||||
target[key] = default_table[key as keyof SettingConfig];
|
||||
diff_occur = true;
|
||||
}
|
||||
return diff_occur;
|
||||
};
|
||||
|
||||
export const read_setting_from_file = () => {
|
||||
const ret = existsSync("settings.json") ? JSON.parse(readFileSync("settings.json", { encoding: "utf8" })) : {};
|
||||
const partial_occur = setEmptyToDefault(ret, default_setting);
|
||||
if (partial_occur) {
|
||||
writeFileSync("settings.json", JSON.stringify(ret));
|
||||
}
|
||||
return ret as SettingConfig;
|
||||
};
|
||||
export function get_setting(): SettingConfig {
|
||||
if (setting === null) {
|
||||
setting = read_setting_from_file();
|
||||
const env = process.env.NODE_ENV;
|
||||
if (env !== undefined && env !== "production" && env !== "development") {
|
||||
throw new Error('process unknown value in NODE_ENV: must be either "development" or "production"');
|
||||
}
|
||||
setting.mode = env ?? setting.mode;
|
||||
}
|
||||
return setting;
|
||||
}
|
22
packages/server/src/config.ts
Normal file
22
packages/server/src/config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { Knex as k } from "knex";
|
||||
|
||||
export namespace Knex {
|
||||
export const config: {
|
||||
development: k.Config;
|
||||
production: k.Config;
|
||||
} = {
|
||||
development: {
|
||||
client: "sqlite3",
|
||||
connection: {
|
||||
filename: "./devdb.sqlite3",
|
||||
},
|
||||
debug: true,
|
||||
},
|
||||
production: {
|
||||
client: "sqlite3",
|
||||
connection: {
|
||||
filename: "./db.sqlite3",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
70
packages/server/src/content/comic.ts
Normal file
70
packages/server/src/content/comic.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { extname } from "node:path";
|
||||
import type { DocumentBody } from "dbtype/api";
|
||||
import { readZip } from "../util/zipwrap";
|
||||
import { type ContentConstructOption, createDefaultClass, registerContentReferrer } from "./file";
|
||||
import { TextWriter } from "@zip.js/zip.js";
|
||||
|
||||
type ComicType = "doujinshi" | "artist cg" | "manga" | "western";
|
||||
interface ComicDesc {
|
||||
title: string;
|
||||
artist?: string[];
|
||||
group?: string[];
|
||||
series?: string[];
|
||||
type: ComicType | [ComicType];
|
||||
character?: string[];
|
||||
tags?: string[];
|
||||
}
|
||||
const ImageExt = [".gif", ".png", ".jpeg", ".bmp", ".webp", ".jpg"];
|
||||
export class ComicReferrer extends createDefaultClass("comic") {
|
||||
desc: ComicDesc | undefined;
|
||||
pagenum: number;
|
||||
additional: ContentConstructOption | undefined;
|
||||
constructor(path: string, option?: ContentConstructOption) {
|
||||
super(path);
|
||||
this.additional = option;
|
||||
this.pagenum = 0;
|
||||
}
|
||||
async initDesc(): Promise<void> {
|
||||
if (this.desc !== undefined) return;
|
||||
const zip = await readZip(this.path);
|
||||
const entries = await zip.reader.getEntries();
|
||||
this.pagenum = entries.filter((x) => ImageExt.includes(extname(x.filename))).length;
|
||||
const descEntry = entries.find(x=> x.filename === "desc.json");
|
||||
if (descEntry === undefined) {
|
||||
return;
|
||||
}
|
||||
if (descEntry.getData === undefined) {
|
||||
throw new Error("entry.getData is undefined");
|
||||
}
|
||||
const textWriter = new TextWriter();
|
||||
const data = (await descEntry.getData(textWriter));
|
||||
this.desc = JSON.parse(data);
|
||||
zip.reader.close()
|
||||
.then(() => zip.handle.close());
|
||||
}
|
||||
|
||||
async createDocumentBody(): Promise<DocumentBody> {
|
||||
await this.initDesc();
|
||||
const basebody = await super.createDocumentBody();
|
||||
this.desc?.title;
|
||||
if (this.desc === undefined) {
|
||||
return basebody;
|
||||
}
|
||||
let tags: string[] = this.desc.tags ?? [];
|
||||
tags = tags.concat(this.desc.artist?.map((x) => `artist:${x}`) ?? []);
|
||||
tags = tags.concat(this.desc.character?.map((x) => `character:${x}`) ?? []);
|
||||
tags = tags.concat(this.desc.group?.map((x) => `group:${x}`) ?? []);
|
||||
tags = tags.concat(this.desc.series?.map((x) => `series:${x}`) ?? []);
|
||||
const type = Array.isArray(this.desc.type) ? this.desc.type[0] : this.desc.type;
|
||||
tags.push(`type:${type}`);
|
||||
return {
|
||||
...basebody,
|
||||
title: this.desc.title,
|
||||
additional: {
|
||||
page: this.pagenum,
|
||||
},
|
||||
tags: tags,
|
||||
};
|
||||
}
|
||||
}
|
||||
registerContentReferrer(ComicReferrer);
|
98
packages/server/src/content/file.ts
Normal file
98
packages/server/src/content/file.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises, type Stats } from "node:fs";
|
||||
import path, { extname } from "node:path";
|
||||
import type { DocumentBody } from "dbtype/api";
|
||||
/**
|
||||
* content file or directory referrer
|
||||
*/
|
||||
export interface ContentFile {
|
||||
getHash(): Promise<string>;
|
||||
createDocumentBody(): Promise<DocumentBody>;
|
||||
readonly path: string;
|
||||
readonly type: string;
|
||||
}
|
||||
export type ContentConstructOption = {
|
||||
hash: string;
|
||||
};
|
||||
type ContentFileConstructor = (new (
|
||||
path: string,
|
||||
option?: ContentConstructOption,
|
||||
) => ContentFile) & {
|
||||
content_type: string;
|
||||
};
|
||||
export const createDefaultClass = (type: string): ContentFileConstructor => {
|
||||
const cons = class implements ContentFile {
|
||||
readonly path: string;
|
||||
// type = type;
|
||||
static content_type = type;
|
||||
protected hash: string | undefined;
|
||||
protected stat: Stats | undefined;
|
||||
|
||||
protected getStat(){
|
||||
return this.stat;
|
||||
}
|
||||
|
||||
constructor(path: string, option?: ContentConstructOption) {
|
||||
this.path = path;
|
||||
this.hash = option?.hash;
|
||||
this.stat = undefined;
|
||||
}
|
||||
async createDocumentBody(): Promise<DocumentBody> {
|
||||
console.log(`createDocumentBody: ${this.path}`);
|
||||
const { base, dir, name } = path.parse(this.path);
|
||||
|
||||
const ret = {
|
||||
title: name,
|
||||
basepath: dir,
|
||||
additional: {},
|
||||
content_type: cons.content_type,
|
||||
filename: base,
|
||||
tags: [],
|
||||
content_hash: await this.getHash(),
|
||||
modified_at: await this.getMtime(),
|
||||
} as DocumentBody;
|
||||
return ret;
|
||||
}
|
||||
get type(): string {
|
||||
return cons.content_type;
|
||||
}
|
||||
async getHash(): Promise<string> {
|
||||
if (this.hash !== undefined) return this.hash;
|
||||
this.stat = await promises.stat(this.path);
|
||||
const hash = createHash("sha512");
|
||||
hash.update(extname(this.path));
|
||||
hash.update(this.stat.mode.toString());
|
||||
// if(this.desc !== undefined)
|
||||
// hash.update(JSON.stringify(this.desc));
|
||||
hash.update(this.stat.size.toString());
|
||||
this.hash = hash.digest("base64");
|
||||
return this.hash;
|
||||
}
|
||||
async getMtime(): Promise<number> {
|
||||
const oldStat = this.getStat();
|
||||
if (oldStat !== undefined) return oldStat.mtimeMs;
|
||||
await this.getHash();
|
||||
const newStat = this.getStat();
|
||||
if (newStat === undefined) throw new Error("stat is undefined");
|
||||
return newStat.mtimeMs;
|
||||
}
|
||||
};
|
||||
return cons;
|
||||
};
|
||||
const ContstructorTable: { [k: string]: ContentFileConstructor } = {};
|
||||
export function registerContentReferrer(s: ContentFileConstructor) {
|
||||
console.log(`registered content type: ${s.content_type}`);
|
||||
ContstructorTable[s.content_type] = s;
|
||||
}
|
||||
export function createContentFile(type: string, path: string, option?: ContentConstructOption) {
|
||||
const constructorMethod = ContstructorTable[type];
|
||||
if (constructorMethod === undefined) {
|
||||
console.log(`${type} are not in ${JSON.stringify(ContstructorTable)}`);
|
||||
throw new Error("construction method of the content type is undefined");
|
||||
}
|
||||
return new constructorMethod(path, option);
|
||||
}
|
||||
export function getContentFileConstructor(type: string): ContentFileConstructor | undefined {
|
||||
const ret = ContstructorTable[type];
|
||||
return ret;
|
||||
}
|
6
packages/server/src/content/video.ts
Normal file
6
packages/server/src/content/video.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { registerContentReferrer } from "./file";
|
||||
import { createDefaultClass } from "./file";
|
||||
|
||||
export class VideoReferrer extends createDefaultClass("video") {
|
||||
}
|
||||
registerContentReferrer(VideoReferrer);
|
26
packages/server/src/database.ts
Normal file
26
packages/server/src/database.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { get_setting } from "./SettingConfig";
|
||||
import { getKysely } from "./db/kysely";
|
||||
|
||||
export async function connectDB() {
|
||||
const kysely = getKysely();
|
||||
|
||||
let tries = 0;
|
||||
for (;;) {
|
||||
try {
|
||||
console.log("try to connect db");
|
||||
await kysely.selectNoFrom(eb=> eb.val(1).as("dummy")).execute();
|
||||
console.log("connect success");
|
||||
} catch (err) {
|
||||
if (tries < 3) {
|
||||
tries++;
|
||||
console.error(`connection fail ${err} retry...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return kysely;
|
||||
}
|
234
packages/server/src/db/doc.ts
Normal file
234
packages/server/src/db/doc.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import { getKysely } from "./kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import type { DocumentAccessor } from "../model/doc";
|
||||
import type {
|
||||
Document,
|
||||
QueryListOption,
|
||||
DocumentBody
|
||||
} from "dbtype/api";
|
||||
import type { NotNull } from "kysely";
|
||||
import { MyParseJSONResultsPlugin } from "./plugin";
|
||||
|
||||
export type DBTagContentRelation = {
|
||||
doc_id: number;
|
||||
tag_name: string;
|
||||
};
|
||||
|
||||
class SqliteDocumentAccessor implements DocumentAccessor {
|
||||
constructor(private kysely = getKysely()) {
|
||||
}
|
||||
async search(search_word: string): Promise<Document[]> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
async addList(content_list: DocumentBody[]): Promise<number[]> {
|
||||
return await this.kysely.transaction().execute(async (trx) => {
|
||||
// add tags
|
||||
const tagCollected = new Set<string>();
|
||||
for (const content of content_list) {
|
||||
for (const tag of content.tags) {
|
||||
tagCollected.add(tag);
|
||||
}
|
||||
}
|
||||
await trx.insertInto("tags")
|
||||
.values(Array.from(tagCollected).map((x) => ({ name: x })))
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
|
||||
const ids = await trx.insertInto("document")
|
||||
.values(content_list.map((content) => {
|
||||
const { tags, additional, ...rest } = content;
|
||||
return {
|
||||
additional: JSON.stringify(additional),
|
||||
created_at: Date.now(),
|
||||
...rest,
|
||||
};
|
||||
}))
|
||||
.returning("id")
|
||||
.execute();
|
||||
const id_lst = ids.map((x) => x.id);
|
||||
|
||||
const doc_tags = content_list.flatMap((content, index) => {
|
||||
const { tags, ...rest } = content;
|
||||
return tags.map((tag) => ({ doc_id: id_lst[index], tag_name: tag }));
|
||||
});
|
||||
await trx.insertInto("doc_tag_relation")
|
||||
.values(doc_tags)
|
||||
.execute();
|
||||
return id_lst;
|
||||
});
|
||||
}
|
||||
async add(c: DocumentBody) {
|
||||
return await this.kysely.transaction().execute(async (trx) => {
|
||||
const { tags, additional, ...rest } = c;
|
||||
const id_lst = await trx.insertInto("document").values({
|
||||
additional: JSON.stringify(additional),
|
||||
created_at: Date.now(),
|
||||
...rest,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirst() as { id: number };
|
||||
const id = id_lst.id;
|
||||
|
||||
// add tags
|
||||
await trx.insertInto("tags")
|
||||
.values(tags.map((x) => ({ name: x })))
|
||||
// on conflict is supported in sqlite and postgresql.
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
|
||||
if (tags.length > 0) {
|
||||
await trx.insertInto("doc_tag_relation")
|
||||
.values(tags.map((x) => ({ doc_id: id, tag_name: x })))
|
||||
.execute();
|
||||
}
|
||||
return id;
|
||||
});
|
||||
}
|
||||
async del(id: number) {
|
||||
// delete tags
|
||||
await this.kysely
|
||||
.deleteFrom("doc_tag_relation")
|
||||
.where("doc_id", "=", id)
|
||||
.execute();
|
||||
// delete document
|
||||
const result = await this.kysely
|
||||
.deleteFrom("document")
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
return result.numDeletedRows > 0;
|
||||
}
|
||||
async findById(id: number, tagload?: boolean): Promise<Document | undefined> {
|
||||
const doc = await this.kysely.selectFrom("document")
|
||||
.selectAll()
|
||||
.where("id", "=", id)
|
||||
.$if(tagload ?? false, (qb) =>
|
||||
qb.select(eb => jsonArrayFrom(
|
||||
eb.selectFrom("doc_tag_relation")
|
||||
.select(["doc_tag_relation.tag_name"])
|
||||
.whereRef("document.id", "=", "doc_tag_relation.doc_id")
|
||||
.select("tag_name")
|
||||
).as("tags")).withPlugin(new MyParseJSONResultsPlugin("tags"))
|
||||
)
|
||||
.executeTakeFirst();
|
||||
if (!doc) return undefined;
|
||||
|
||||
return {
|
||||
...doc,
|
||||
content_hash: doc.content_hash ?? "",
|
||||
additional: doc.additional !== null ? JSON.parse(doc.additional) : {},
|
||||
tags: doc.tags?.map((x: { tag_name: string }) => x.tag_name) ?? [],
|
||||
};
|
||||
}
|
||||
async findDeleted(content_type: string) {
|
||||
const docs = await this.kysely
|
||||
.selectFrom("document")
|
||||
.selectAll()
|
||||
.where("content_type", "=", content_type)
|
||||
.where("deleted_at", "is not", null)
|
||||
.$narrowType<{ deleted_at: NotNull }>()
|
||||
.execute();
|
||||
return docs.map((x) => ({
|
||||
...x,
|
||||
tags: [],
|
||||
content_hash: x.content_hash ?? "",
|
||||
additional: {},
|
||||
}));
|
||||
}
|
||||
async findList(option?: QueryListOption) {
|
||||
const {
|
||||
allow_tag = [],
|
||||
eager_loading = true,
|
||||
limit = 20,
|
||||
use_offset = false,
|
||||
offset = 0,
|
||||
word,
|
||||
content_type,
|
||||
cursor,
|
||||
} = option ?? {};
|
||||
|
||||
const result = await this.kysely
|
||||
.selectFrom("document")
|
||||
.selectAll()
|
||||
.$if(allow_tag.length > 0, (qb) => {
|
||||
return allow_tag.reduce((prevQb, tag, index) => {
|
||||
return prevQb.innerJoin(`doc_tag_relation as tags_${index}`, `tags_${index}.doc_id`, "document.id")
|
||||
.where(`tags_${index}.tag_name`, "=", tag);
|
||||
}, qb) as unknown as typeof qb;
|
||||
})
|
||||
.$if(word !== undefined, (qb) => qb.where("title", "like", `%${word}%`))
|
||||
.$if(content_type !== undefined, (qb) => qb.where("content_type", "=", content_type as string))
|
||||
.$if(use_offset, (qb) => qb.offset(offset))
|
||||
.$if(!use_offset && cursor !== undefined, (qb) => qb.where("id", "<", cursor as number))
|
||||
.limit(limit)
|
||||
.$if(eager_loading, (qb) => {
|
||||
return qb.select(eb =>
|
||||
eb.selectFrom(e =>
|
||||
e.selectFrom("doc_tag_relation")
|
||||
.select(["doc_tag_relation.tag_name"])
|
||||
.whereRef("document.id", "=", "doc_tag_relation.doc_id")
|
||||
.as("agg")
|
||||
).select(e => e.fn.agg<string>("json_group_array", ["agg.tag_name"])
|
||||
.as("tags_list")
|
||||
).as("tags")
|
||||
)
|
||||
})
|
||||
.orderBy("id", "desc")
|
||||
.execute();
|
||||
return result.map((x) => ({
|
||||
...x,
|
||||
content_hash: x.content_hash ?? "",
|
||||
additional: x.additional !== null ? (JSON.parse(x.additional)) : {},
|
||||
tags: JSON.parse(x.tags ?? "[]"), //?.map((x: { tag_name: string }) => x.tag_name) ?? [],
|
||||
}));
|
||||
}
|
||||
async findByPath(path: string, filename?: string): Promise<Document[]> {
|
||||
const results = await this.kysely
|
||||
.selectFrom("document")
|
||||
.selectAll()
|
||||
.where("basepath", "=", path)
|
||||
.$if(filename !== undefined, (qb) => qb.where("filename", "=", filename as string))
|
||||
.execute();
|
||||
return results.map((x) => ({
|
||||
...x,
|
||||
content_hash: x.content_hash ?? "",
|
||||
tags: [],
|
||||
additional: {},
|
||||
}));
|
||||
}
|
||||
async update(c: Partial<Document> & { id: number }) {
|
||||
const { id, tags, additional, ...rest } = c;
|
||||
const r = await this.kysely.updateTable("document")
|
||||
.set({
|
||||
...rest,
|
||||
modified_at: Date.now(),
|
||||
additional: additional !== undefined ? JSON.stringify(additional) : undefined,
|
||||
})
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
return r.numUpdatedRows > 0;
|
||||
}
|
||||
async addTag(c: Document, tag_name: string) {
|
||||
if (c.tags.includes(tag_name)) return false;
|
||||
await this.kysely.insertInto("tags")
|
||||
.values({ name: tag_name })
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
await this.kysely.insertInto("doc_tag_relation")
|
||||
.values({ tag_name: tag_name, doc_id: c.id })
|
||||
.execute();
|
||||
c.tags.push(tag_name);
|
||||
return true;
|
||||
}
|
||||
async delTag(c: Document, tag_name: string) {
|
||||
if (c.tags.includes(tag_name)) return false;
|
||||
await this.kysely.deleteFrom("doc_tag_relation")
|
||||
.where("tag_name", "=", tag_name)
|
||||
.where("doc_id", "=", c.id)
|
||||
.execute();
|
||||
c.tags.splice(c.tags.indexOf(tag_name), 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
export const createSqliteDocumentAccessor = (kysely = getKysely()): DocumentAccessor => {
|
||||
return new SqliteDocumentAccessor(kysely);
|
||||
};
|
26
packages/server/src/db/kysely.ts
Normal file
26
packages/server/src/db/kysely.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Kysely, ParseJSONResultsPlugin, SqliteDialect } from "kysely";
|
||||
import SqliteDatabase from "better-sqlite3";
|
||||
import type { DB } from "dbtype/types";
|
||||
|
||||
export function createSqliteDialect() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
const db = new SqliteDatabase(url);
|
||||
return new SqliteDialect({
|
||||
database: db,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new Kysely instance with a new SqliteDatabase instance
|
||||
let kysely: Kysely<DB> | null = null;
|
||||
export function getKysely() {
|
||||
if (!kysely) {
|
||||
kysely = new Kysely<DB>({
|
||||
dialect: createSqliteDialect(),
|
||||
// plugins: [new ParseJSONResultsPlugin()],
|
||||
});
|
||||
}
|
||||
return kysely;
|
||||
}
|
24
packages/server/src/db/plugin.ts
Normal file
24
packages/server/src/db/plugin.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import type { KyselyPlugin, PluginTransformQueryArgs, PluginTransformResultArgs, QueryResult, RootOperationNode, UnknownRow } from "kysely";
|
||||
|
||||
export class MyParseJSONResultsPlugin implements KyselyPlugin {
|
||||
|
||||
constructor(private readonly itemPath: string) { }
|
||||
|
||||
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
||||
// do nothing
|
||||
return args.node;
|
||||
}
|
||||
async transformResult(args: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> {
|
||||
return {
|
||||
...args.result,
|
||||
rows: args.result.rows.map((row) => {
|
||||
const newRow = { ...row };
|
||||
const item = newRow[this.itemPath];
|
||||
if (typeof item === "string") {
|
||||
newRow[this.itemPath] = JSON.parse(item);
|
||||
}
|
||||
return newRow;
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
65
packages/server/src/db/tag.ts
Normal file
65
packages/server/src/db/tag.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { getKysely } from "./kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import type { Tag, TagAccessor, TagCount } from "../model/tag";
|
||||
import type { DBTagContentRelation } from "./doc";
|
||||
|
||||
class SqliteTagAccessor implements TagAccessor {
|
||||
|
||||
constructor(private kysely = getKysely()) {
|
||||
}
|
||||
async getAllTagCount(): Promise<TagCount[]> {
|
||||
const result = await this.kysely
|
||||
.selectFrom("doc_tag_relation")
|
||||
.select("tag_name")
|
||||
.select(qb => qb.fn.count<number>("doc_id").as("occurs"))
|
||||
.groupBy("tag_name")
|
||||
.execute();
|
||||
return result;
|
||||
}
|
||||
async getAllTagList(): Promise<Tag[]> {
|
||||
return (await this.kysely.selectFrom("tags")
|
||||
.selectAll()
|
||||
.execute()
|
||||
).map((x) => ({
|
||||
name: x.name,
|
||||
description: x.description ?? undefined,
|
||||
}));
|
||||
}
|
||||
async getTagByName(name: string) {
|
||||
const result = await this.kysely
|
||||
.selectFrom("tags")
|
||||
.selectAll()
|
||||
.where("name", "=", name)
|
||||
.executeTakeFirst();
|
||||
if (result === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
name: result.name,
|
||||
description: result.description ?? undefined,
|
||||
};
|
||||
}
|
||||
async addTag(tag: Tag) {
|
||||
const result = await this.kysely.insertInto("tags")
|
||||
.values([tag])
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.executeTakeFirst();
|
||||
return (result.numInsertedOrUpdatedRows ?? 0n) > 0;
|
||||
}
|
||||
async delTag(name: string) {
|
||||
const result = await this.kysely.deleteFrom("tags")
|
||||
.where("name", "=", name)
|
||||
.executeTakeFirst();
|
||||
return (result.numDeletedRows ?? 0n) > 0;
|
||||
}
|
||||
async updateTag(name: string, desc: string) {
|
||||
const result = await this.kysely.updateTable("tags")
|
||||
.set({ description: desc })
|
||||
.where("name", "=", name)
|
||||
.executeTakeFirst();
|
||||
return (result.numUpdatedRows ?? 0n) > 0;
|
||||
}
|
||||
}
|
||||
export const createSqliteTagController = (kysely = getKysely()): TagAccessor => {
|
||||
return new SqliteTagAccessor(kysely);
|
||||
};
|
87
packages/server/src/db/user.ts
Normal file
87
packages/server/src/db/user.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { getKysely } from "./kysely";
|
||||
import { type IUser, Password, type UserAccessor, type UserCreateInput } from "../model/user";
|
||||
|
||||
class SqliteUser implements IUser {
|
||||
readonly username: string;
|
||||
readonly password: Password;
|
||||
|
||||
constructor(username: string, pw: Password, private kysely = getKysely()) {
|
||||
this.username = username;
|
||||
this.password = pw;
|
||||
}
|
||||
async reset_password(password: string) {
|
||||
this.password.set_password(password);
|
||||
await this.kysely
|
||||
.updateTable("users")
|
||||
.where("username", "=", this.username)
|
||||
.set({ password_hash: this.password.hash, password_salt: this.password.salt })
|
||||
.execute();
|
||||
}
|
||||
async get_permissions() {
|
||||
const permissions = await this.kysely
|
||||
.selectFrom("permissions")
|
||||
.selectAll()
|
||||
.where("username", "=", this.username)
|
||||
.execute();
|
||||
return permissions.map((x) => x.name);
|
||||
}
|
||||
async add(name: string) {
|
||||
const result = await this.kysely
|
||||
.insertInto("permissions")
|
||||
.values({ username: this.username, name })
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.executeTakeFirst();
|
||||
return (result.numInsertedOrUpdatedRows ?? 0n) > 0;
|
||||
}
|
||||
async remove(name: string) {
|
||||
const result = await this.kysely
|
||||
.deleteFrom("permissions")
|
||||
.where("username", "=", this.username)
|
||||
.where("name", "=", name)
|
||||
.executeTakeFirst();
|
||||
return (result.numDeletedRows ?? 0n) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const createSqliteUserController = (kysely = getKysely()): UserAccessor => {
|
||||
const createUser = async (input: UserCreateInput) => {
|
||||
if (undefined !== (await findUser(input.username))) {
|
||||
return undefined;
|
||||
}
|
||||
const user = new SqliteUser(input.username, new Password(input.password), kysely);
|
||||
await kysely
|
||||
.insertInto("users")
|
||||
.values({ username: user.username, password_hash: user.password.hash, password_salt: user.password.salt })
|
||||
.execute();
|
||||
return user;
|
||||
};
|
||||
const findUser = async (id: string) => {
|
||||
const user = await kysely
|
||||
.selectFrom("users")
|
||||
.selectAll()
|
||||
.where("username", "=", id)
|
||||
.executeTakeFirst();
|
||||
if (!user) return undefined;
|
||||
if (!user.password_hash || !user.password_salt) {
|
||||
throw new Error("password hash or salt is missing");
|
||||
}
|
||||
if (user.username === null) {
|
||||
throw new Error("username is null");
|
||||
}
|
||||
return new SqliteUser(user.username, new Password({
|
||||
hash: user.password_hash,
|
||||
salt: user.password_salt
|
||||
}), kysely);
|
||||
};
|
||||
const delUser = async (id: string) => {
|
||||
const result = await kysely.deleteFrom("users")
|
||||
.where("username", "=", id)
|
||||
.executeTakeFirst();
|
||||
return (result.numDeletedRows ?? 0n) > 0;
|
||||
};
|
||||
return {
|
||||
createUser: createUser,
|
||||
findUser: findUser,
|
||||
delUser: delUser,
|
||||
};
|
||||
};
|
121
packages/server/src/diff/content_handler.ts
Normal file
121
packages/server/src/diff/content_handler.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { basename, dirname, join as pathjoin } from "node:path";
|
||||
import { ContentFile, createContentFile } from "../content/mod";
|
||||
import type { Document, DocumentAccessor } from "../model/mod";
|
||||
import { ContentList } from "./content_list";
|
||||
import type { IDiffWatcher } from "./watcher";
|
||||
|
||||
// refactoring needed.
|
||||
export class ContentDiffHandler {
|
||||
/** content file list waiting to add */
|
||||
waiting_list: ContentList;
|
||||
/** deleted contents */
|
||||
tombstone: Map<string, Document>; // hash, contentfile
|
||||
doc_cntr: DocumentAccessor;
|
||||
/** content type of handle */
|
||||
content_type: string;
|
||||
constructor(cntr: DocumentAccessor, content_type: string) {
|
||||
this.waiting_list = new ContentList();
|
||||
this.tombstone = new Map<string, Document>();
|
||||
this.doc_cntr = cntr;
|
||||
this.content_type = content_type;
|
||||
}
|
||||
async setup() {
|
||||
const deleted = await this.doc_cntr.findDeleted(this.content_type);
|
||||
for (const it of deleted) {
|
||||
this.tombstone.set(it.content_hash, it);
|
||||
}
|
||||
}
|
||||
register(diff: IDiffWatcher) {
|
||||
diff
|
||||
.on("create", (path) => this.OnCreated(path))
|
||||
.on("delete", (path) => this.OnDeleted(path))
|
||||
.on("change", (prev, cur) => this.OnChanged(prev, cur));
|
||||
}
|
||||
private async OnDeleted(cpath: string) {
|
||||
const basepath = dirname(cpath);
|
||||
const filename = basename(cpath);
|
||||
console.log("deleted ", cpath);
|
||||
// if it wait to add, delete it from waiting list.
|
||||
if (this.waiting_list.hasByPath(cpath)) {
|
||||
this.waiting_list.deleteByPath(cpath);
|
||||
return;
|
||||
}
|
||||
const dbc = await this.doc_cntr.findByPath(basepath, filename);
|
||||
// when there is no related content in db, ignore.
|
||||
if (dbc.length === 0) {
|
||||
console.log("its not in waiting_list and db!!!: ", cpath);
|
||||
return;
|
||||
}
|
||||
const content_hash = dbc[0].content_hash;
|
||||
// When a path is changed, it takes into account when the
|
||||
// creation event occurs first and the deletion occurs, not
|
||||
// the change event.
|
||||
const cf = this.waiting_list.getByHash(content_hash);
|
||||
if (cf) {
|
||||
// if a path is changed, update the changed path.
|
||||
console.log("update path from", cpath, "to", cf.path);
|
||||
const newFilename = basename(cf.path);
|
||||
const newBasepath = dirname(cf.path);
|
||||
this.waiting_list.deleteByHash(content_hash);
|
||||
await this.doc_cntr.update({
|
||||
id: dbc[0].id,
|
||||
deleted_at: null,
|
||||
filename: newFilename,
|
||||
basepath: newBasepath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// invalidate db and add it to tombstone.
|
||||
await this.doc_cntr.update({
|
||||
id: dbc[0].id,
|
||||
deleted_at: Date.now(),
|
||||
});
|
||||
this.tombstone.set(dbc[0].content_hash, dbc[0]);
|
||||
}
|
||||
private async OnCreated(cpath: string) {
|
||||
const basepath = dirname(cpath);
|
||||
const filename = basename(cpath);
|
||||
console.log("createContentFile", cpath);
|
||||
const content = createContentFile(this.content_type, cpath);
|
||||
const hash = await content.getHash();
|
||||
const c = this.tombstone.get(hash);
|
||||
if (c !== undefined) {
|
||||
await this.doc_cntr.update({
|
||||
id: c.id,
|
||||
deleted_at: null,
|
||||
filename: filename,
|
||||
basepath: basepath,
|
||||
});
|
||||
}
|
||||
if (this.waiting_list.hasByHash(hash)) {
|
||||
console.log("Hash Conflict!!!");
|
||||
}
|
||||
this.waiting_list.set(content);
|
||||
}
|
||||
private async OnChanged(prev_path: string, cur_path: string) {
|
||||
const prev_basepath = dirname(prev_path);
|
||||
const prev_filename = basename(prev_path);
|
||||
const cur_basepath = dirname(cur_path);
|
||||
const cur_filename = basename(cur_path);
|
||||
console.log("modify", cur_path, "from", prev_path);
|
||||
const c = this.waiting_list.getByPath(prev_path);
|
||||
if (c !== undefined) {
|
||||
await this.waiting_list.delete(c);
|
||||
const content = createContentFile(this.content_type, cur_path);
|
||||
await this.waiting_list.set(content);
|
||||
return;
|
||||
}
|
||||
const doc = await this.doc_cntr.findByPath(prev_basepath, prev_filename);
|
||||
|
||||
if (doc.length === 0) {
|
||||
await this.OnCreated(cur_path);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.doc_cntr.update({
|
||||
...doc[0],
|
||||
basepath: cur_basepath,
|
||||
filename: cur_filename,
|
||||
});
|
||||
}
|
||||
}
|
59
packages/server/src/diff/content_list.ts
Normal file
59
packages/server/src/diff/content_list.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type { ContentFile } from "../content/mod";
|
||||
|
||||
export class ContentList {
|
||||
/** path map */
|
||||
private cl: Map<string, ContentFile>;
|
||||
/** hash map */
|
||||
private hl: Map<string, ContentFile>;
|
||||
|
||||
constructor() {
|
||||
this.cl = new Map();
|
||||
this.hl = new Map();
|
||||
}
|
||||
hasByHash(s: string) {
|
||||
return this.hl.has(s);
|
||||
}
|
||||
hasByPath(p: string) {
|
||||
return this.cl.has(p);
|
||||
}
|
||||
getByHash(s: string) {
|
||||
return this.hl.get(s);
|
||||
}
|
||||
getByPath(p: string) {
|
||||
return this.cl.get(p);
|
||||
}
|
||||
async set(c: ContentFile) {
|
||||
const path = c.path;
|
||||
const hash = await c.getHash();
|
||||
this.cl.set(path, c);
|
||||
this.hl.set(hash, c);
|
||||
}
|
||||
/** delete content file */
|
||||
async delete(c: ContentFile) {
|
||||
const hash = await c.getHash();
|
||||
let r = true;
|
||||
r = this.cl.delete(c.path) && r;
|
||||
r = this.hl.delete(hash) && r;
|
||||
return r;
|
||||
}
|
||||
async deleteByPath(p: string) {
|
||||
const o = this.getByPath(p);
|
||||
if (o === undefined) return false;
|
||||
return await this.delete(o);
|
||||
}
|
||||
deleteByHash(s: string) {
|
||||
const o = this.getByHash(s);
|
||||
if (o === undefined) return false;
|
||||
let r = true;
|
||||
r = this.cl.delete(o.path) && r;
|
||||
r = this.hl.delete(s) && r;
|
||||
return r;
|
||||
}
|
||||
clear() {
|
||||
this.cl.clear();
|
||||
this.hl.clear();
|
||||
}
|
||||
getAll() {
|
||||
return [...this.cl.values()];
|
||||
}
|
||||
}
|
46
packages/server/src/diff/diff.ts
Normal file
46
packages/server/src/diff/diff.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import asyncPool from "tiny-async-pool";
|
||||
import type { DocumentAccessor } from "../model/doc";
|
||||
import { ContentDiffHandler } from "./content_handler";
|
||||
import type { IDiffWatcher } from "./watcher";
|
||||
|
||||
export class DiffManager {
|
||||
watching: { [content_type: string]: ContentDiffHandler };
|
||||
doc_cntr: DocumentAccessor;
|
||||
constructor(contorller: DocumentAccessor) {
|
||||
this.watching = {};
|
||||
this.doc_cntr = contorller;
|
||||
}
|
||||
async register(content_type: string, watcher: IDiffWatcher) {
|
||||
if (this.watching[content_type] === undefined) {
|
||||
this.watching[content_type] = new ContentDiffHandler(this.doc_cntr, content_type);
|
||||
}
|
||||
this.watching[content_type].register(watcher);
|
||||
await watcher.setup(this.doc_cntr);
|
||||
}
|
||||
async commit(type: string, path: string) {
|
||||
const list = this.watching[type].waiting_list;
|
||||
const c = list.getByPath(path);
|
||||
if (c === undefined) {
|
||||
throw new Error("path is not exist");
|
||||
}
|
||||
await list.delete(c);
|
||||
console.log(`commit: ${c.path} ${c.type}`);
|
||||
const body = await c.createDocumentBody();
|
||||
const id = await this.doc_cntr.add(body);
|
||||
return id;
|
||||
}
|
||||
async commitAll(type: string) {
|
||||
const list = this.watching[type].waiting_list;
|
||||
const contentFiles = list.getAll();
|
||||
list.clear();
|
||||
const bodies = await asyncPool(30, contentFiles, async (x) => await x.createDocumentBody());
|
||||
const ids = await this.doc_cntr.addList(bodies);
|
||||
return ids;
|
||||
}
|
||||
getAdded() {
|
||||
return Object.keys(this.watching).map((x) => ({
|
||||
type: x,
|
||||
value: this.watching[x].waiting_list.getAll(),
|
||||
}));
|
||||
}
|
||||
}
|
85
packages/server/src/diff/router.ts
Normal file
85
packages/server/src/diff/router.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import type Koa from "koa";
|
||||
import Router from "koa-router";
|
||||
import type { ContentFile } from "../content/mod";
|
||||
import { AdminOnlyMiddleware } from "../permission/permission";
|
||||
import { sendError } from "../route/error_handler";
|
||||
import type { DiffManager } from "./diff";
|
||||
|
||||
function content_file_to_return(x: ContentFile) {
|
||||
return { path: x.path, type: x.type };
|
||||
}
|
||||
|
||||
export const getAdded = (diffmgr: DiffManager) => (ctx: Koa.Context, next: Koa.Next) => {
|
||||
const ret = diffmgr.getAdded();
|
||||
ctx.body = ret.map((x) => ({
|
||||
type: x.type,
|
||||
value: x.value.map((x) => ({ path: x.path, type: x.type })),
|
||||
}));
|
||||
ctx.type = "json";
|
||||
};
|
||||
|
||||
type PostAddedBody = {
|
||||
type: string;
|
||||
path: string;
|
||||
}[];
|
||||
|
||||
function checkPostAddedBody(body: unknown): body is PostAddedBody {
|
||||
if (Array.isArray(body)) {
|
||||
return body.map((x) => "type" in x && "path" in x).every((x) => x);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const postAdded = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => {
|
||||
const reqbody = ctx.request.body;
|
||||
if (!checkPostAddedBody(reqbody)) {
|
||||
sendError(400, "format exception");
|
||||
return;
|
||||
}
|
||||
const allWork = reqbody.map((op) => diffmgr.commit(op.type, op.path));
|
||||
const results = await Promise.all(allWork);
|
||||
ctx.body = {
|
||||
ok: true,
|
||||
docs: results,
|
||||
};
|
||||
ctx.type = "json";
|
||||
await next();
|
||||
};
|
||||
export const postAddedAll = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => {
|
||||
if (!ctx.is("json")) {
|
||||
sendError(400, "format exception");
|
||||
return;
|
||||
}
|
||||
const reqbody = ctx.request.body as Record<string, unknown>;
|
||||
if (!("type" in reqbody)) {
|
||||
sendError(400, 'format exception: there is no "type"');
|
||||
return;
|
||||
}
|
||||
const t = reqbody.type;
|
||||
if (typeof t !== "string") {
|
||||
sendError(400, 'format exception: invalid type of "type"');
|
||||
return;
|
||||
}
|
||||
await diffmgr.commitAll(t);
|
||||
ctx.body = {
|
||||
ok: true,
|
||||
};
|
||||
ctx.type = "json";
|
||||
await next();
|
||||
};
|
||||
/*
|
||||
export const getNotWatched = (diffmgr : DiffManager)=> (ctx:Router.IRouterContext,next:Koa.Next)=>{
|
||||
ctx.body = {
|
||||
added: diffmgr.added.map(content_file_to_return),
|
||||
deleted: diffmgr.deleted.map(content_file_to_return),
|
||||
};
|
||||
ctx.type = 'json';
|
||||
}*/
|
||||
|
||||
export function createDiffRouter(diffmgr: DiffManager) {
|
||||
const ret = new Router();
|
||||
ret.get("/list", AdminOnlyMiddleware, getAdded(diffmgr));
|
||||
ret.post("/commit", AdminOnlyMiddleware, postAdded(diffmgr));
|
||||
ret.post("/commitall", AdminOnlyMiddleware, postAddedAll(diffmgr));
|
||||
return ret;
|
||||
}
|
25
packages/server/src/diff/watcher.ts
Normal file
25
packages/server/src/diff/watcher.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type event from "node:events";
|
||||
import { FSWatcher, watch } from "node:fs";
|
||||
import { promises } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { DocumentAccessor } from "../model/doc";
|
||||
|
||||
const readdir = promises.readdir;
|
||||
|
||||
export interface DiffWatcherEvent {
|
||||
create: (path: string) => void;
|
||||
delete: (path: string) => void;
|
||||
change: (prev_path: string, cur_path: string) => void;
|
||||
}
|
||||
|
||||
export interface IDiffWatcher extends event.EventEmitter {
|
||||
on<U extends keyof DiffWatcherEvent>(event: U, listener: DiffWatcherEvent[U]): this;
|
||||
emit<U extends keyof DiffWatcherEvent>(event: U, ...arg: Parameters<DiffWatcherEvent[U]>): boolean;
|
||||
setup(cntr: DocumentAccessor): Promise<void>;
|
||||
}
|
||||
|
||||
export function linkWatcher(fromWatcher: IDiffWatcher, toWatcher: IDiffWatcher) {
|
||||
fromWatcher.on("create", (p) => toWatcher.emit("create", p));
|
||||
fromWatcher.on("delete", (p) => toWatcher.emit("delete", p));
|
||||
fromWatcher.on("change", (p, c) => toWatcher.emit("change", p, c));
|
||||
}
|
12
packages/server/src/diff/watcher/ComicConfig.schema.json
Normal file
12
packages/server/src/diff/watcher/ComicConfig.schema.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$ref": "#/definitions/ComicConfig",
|
||||
"definitions": {
|
||||
"ComicConfig": {
|
||||
"type": "object",
|
||||
"properties": { "watch": { "type": "array", "items": { "type": "string" } }, "$schema": { "type": "string" } },
|
||||
"required": ["watch"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
13
packages/server/src/diff/watcher/comic_watcher.ts
Normal file
13
packages/server/src/diff/watcher/comic_watcher.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ComicConfig } from "./ComicConfig";
|
||||
import { WatcherCompositer } from "./compositer";
|
||||
import { RecursiveWatcher } from "./recursive_watcher";
|
||||
import { WatcherFilter } from "./watcher_filter";
|
||||
|
||||
const createComicWatcherBase = (path: string) => {
|
||||
return new WatcherFilter(new RecursiveWatcher(path), (x) => x.endsWith(".zip"));
|
||||
};
|
||||
export const createComicWatcher = () => {
|
||||
const file = ComicConfig.get_config_file();
|
||||
console.log(`register comic ${file.watch.join(",")}`);
|
||||
return new WatcherCompositer(file.watch.map((path) => createComicWatcherBase(path)));
|
||||
};
|
44
packages/server/src/diff/watcher/common_watcher.ts
Normal file
44
packages/server/src/diff/watcher/common_watcher.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import event from "node:events";
|
||||
import { type FSWatcher, promises, watch } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { DocumentAccessor } from "../../model/doc";
|
||||
import type { DiffWatcherEvent, IDiffWatcher } from "../watcher";
|
||||
import { setupHelp } from "./util";
|
||||
|
||||
const { readdir } = promises;
|
||||
|
||||
export class CommonDiffWatcher extends event.EventEmitter implements IDiffWatcher {
|
||||
on<U extends keyof DiffWatcherEvent>(event: U, listener: DiffWatcherEvent[U]): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
emit<U extends keyof DiffWatcherEvent>(event: U, ...arg: Parameters<DiffWatcherEvent[U]>): boolean {
|
||||
return super.emit(event, ...arg);
|
||||
}
|
||||
private _path: string;
|
||||
private _watcher: FSWatcher;
|
||||
|
||||
constructor(path: string) {
|
||||
super();
|
||||
this._path = path;
|
||||
this._watcher = watch(this._path, { persistent: true, recursive: false }, async (eventType, filename) => {
|
||||
if (eventType === "rename") {
|
||||
const cur = await readdir(this._path);
|
||||
// add
|
||||
if (cur.includes(filename)) {
|
||||
this.emit("create", join(this.path, filename));
|
||||
} else {
|
||||
this.emit("delete", join(this.path, filename));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async setup(cntr: DocumentAccessor): Promise<void> {
|
||||
await setupHelp(this, this.path, cntr);
|
||||
}
|
||||
public get path() {
|
||||
return this._path;
|
||||
}
|
||||
watchClose() {
|
||||
this._watcher.close();
|
||||
}
|
||||
}
|
23
packages/server/src/diff/watcher/compositer.ts
Normal file
23
packages/server/src/diff/watcher/compositer.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { DocumentAccessor } from "../../model/doc";
|
||||
import { type DiffWatcherEvent, type IDiffWatcher, linkWatcher } from "../watcher";
|
||||
|
||||
export class WatcherCompositer extends EventEmitter implements IDiffWatcher {
|
||||
refWatchers: IDiffWatcher[];
|
||||
on<U extends keyof DiffWatcherEvent>(event: U, listener: DiffWatcherEvent[U]): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
emit<U extends keyof DiffWatcherEvent>(event: U, ...arg: Parameters<DiffWatcherEvent[U]>): boolean {
|
||||
return super.emit(event, ...arg);
|
||||
}
|
||||
constructor(refWatchers: IDiffWatcher[]) {
|
||||
super();
|
||||
this.refWatchers = refWatchers;
|
||||
for (const refWatcher of this.refWatchers) {
|
||||
linkWatcher(refWatcher, this);
|
||||
}
|
||||
}
|
||||
async setup(cntr: DocumentAccessor): Promise<void> {
|
||||
await Promise.all(this.refWatchers.map((x) => x.setup(cntr)));
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user