Rework #6
					 197 changed files with 10045 additions and 8733 deletions
				
			
		
							
								
								
									
										6
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							| 
						 | 
					@ -12,6 +12,10 @@ db.sqlite3
 | 
				
			||||||
build/**
 | 
					build/**
 | 
				
			||||||
app/**
 | 
					app/**
 | 
				
			||||||
settings.json
 | 
					settings.json
 | 
				
			||||||
*config.json
 | 
					comic_config.json
 | 
				
			||||||
 | 
					**/comic_config.json
 | 
				
			||||||
 | 
					compiled/
 | 
				
			||||||
 | 
					deploy-scripts/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.pnpm-store/**
 | 
					.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.");
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										88
									
								
								package.json
									
										
									
									
									
								
							
							
						
						
									
										88
									
								
								package.json
									
										
									
									
									
								
							| 
						 | 
					@ -1,86 +1,20 @@
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
  "name": "followed",
 | 
					  "name": "ionian",
 | 
				
			||||||
  "version": "1.0.0",
 | 
					  "version": "1.0.0",
 | 
				
			||||||
  "description": "",
 | 
					  "description": "",
 | 
				
			||||||
  "main": "build/app.js",
 | 
					  "main": "index.js",
 | 
				
			||||||
  "scripts": {
 | 
					  "scripts": {
 | 
				
			||||||
    "compile": "tsc",
 | 
					    "test": "echo \"Error: no test specified\" && exit 1",
 | 
				
			||||||
    "compile:watch": "tsc -w",
 | 
					    "format": "biome format --write",
 | 
				
			||||||
    "build": "cd src/client && pnpm run build:prod",
 | 
					    "lint": "biome lint"
 | 
				
			||||||
    "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"
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  "build": {
 | 
					 | 
				
			||||||
    "asar": true,
 | 
					 | 
				
			||||||
    "files": [
 | 
					 | 
				
			||||||
      "build/**/*",
 | 
					 | 
				
			||||||
      "node_modules/**/*",
 | 
					 | 
				
			||||||
      "package.json"
 | 
					 | 
				
			||||||
    ],
 | 
					 | 
				
			||||||
    "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": "."
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  "keywords": [],
 | 
				
			||||||
 | 
					  "workspaces": [
 | 
				
			||||||
 | 
							"packages/*"
 | 
				
			||||||
 | 
						],
 | 
				
			||||||
  "author": "",
 | 
					  "author": "",
 | 
				
			||||||
  "license": "ISC",
 | 
					  "license": "MIT",
 | 
				
			||||||
  "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"
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
    "@types/jsonwebtoken": "^8.5.8",
 | 
					    "@biomejs/biome": "1.6.3"
 | 
				
			||||||
    "@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"
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										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 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
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,7 @@
 | 
				
			||||||
import { ConfigManager } from "../../util/configRW";
 | 
					import { ConfigManager } from "../../util/configRW";
 | 
				
			||||||
import ComicSchema from "./ComicConfig.schema.json";
 | 
					import ComicSchema from "./ComicConfig.schema.json";
 | 
				
			||||||
export interface ComicConfig {
 | 
					export interface ComicConfig {
 | 
				
			||||||
    watch: string[];
 | 
						watch: string[];
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ComicConfig = new ConfigManager<ComicConfig>("comic_config.json", { watch: [] }, ComicSchema);
 | 
					export const ComicConfig = new ConfigManager<ComicConfig>("comic_config.json", { watch: [] }, ComicSchema);
 | 
				
			||||||
							
								
								
									
										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…
	
	Add table
		
		Reference in a new issue