BREAKING: Rework (#6)

다시 작업. 디자인도 바꾸고 서버도 바꿈.

Co-authored-by: monoid <jaeung@prelude.duckdns.org>
Reviewed-on: https://git.prelude.duckdns.org/monoid/ionian/pulls/6
This commit is contained in:
monoid 2024-04-17 01:45:36 +09:00
parent 8c605af817
commit 8fece9090f
197 changed files with 10045 additions and 8733 deletions

8
.gitignore vendored
View File

@ -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
View File

@ -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
View 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
}
}

View File

@ -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"
]
}

View File

@ -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");

View File

@ -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>

View File

@ -1,5 +0,0 @@
require("ts-node").register();
const { Knex } = require("./src/config");
// Update with your config settings.
module.exports = Knex.config;

View File

@ -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.");
}

View File

@ -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"
} }
} }

View 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
View 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
View 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

View 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"
}
}

View 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>

View 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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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

View 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;
}

View 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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View 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>;
}

View 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>;
}

View 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);

View 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" />;
}

View 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>
}

View 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>;
}

View 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>
</>
}

View 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>
);
}

View 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>
}

View 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;
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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();
}

View 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;
}

View 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);
}

View 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),
};
});
}

View 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);
}

View 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;
}
}

View 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());
};
}

View 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;
}

View 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))
}

View 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>,
)

View 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;

View 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;

View 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;

View 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>;
}

View 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;

View 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;

View 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>
)
}

View 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;

View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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")],
}

View 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" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View 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
View 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;
};

View 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
View 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
View File

@ -0,0 +1,7 @@
import { create_server } from "./src/server";
create_server().then((server) => {
server.start_server();
}).catch((err) => {
console.error(err);
});

View 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");

View 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.");
}

View 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"
}
}

View 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);
// },
// });

View 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"]
}
}
}

View 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;
}

View 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",
},
},
};
}

View 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);

View 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;
}

View File

@ -0,0 +1,6 @@
import { registerContentReferrer } from "./file";
import { createDefaultClass } from "./file";
export class VideoReferrer extends createDefaultClass("video") {
}
registerContentReferrer(VideoReferrer);

View 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;
}

View 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);
};

View 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;
}

View 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;
})
}
}
}

View 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);
};

View 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,
};
};

View 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,
});
}
}

View 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()];
}
}

View 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(),
}));
}
}

View 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;
}

View 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));
}

View 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
}
}
}

View File

@ -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);

View 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)));
};

View 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();
}
}

View 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