diff --git a/.babelrc b/.babelrc
new file mode 100644
index 0000000..2961d01
--- /dev/null
+++ b/.babelrc
@@ -0,0 +1,11 @@
+{
+ "presets": [
+ "@babel/preset-env",
+ "@babel/preset-typescript",
+ "@babel/preset-react"
+ ],
+ "plugins": [
+ "@babel/proposal-class-properties",
+ "@babel/proposal-object-rest-spread"
+ ]
+}
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..44262bb
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ react-sample
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/knexfile.js b/knexfile.js
new file mode 100644
index 0000000..54a887a
--- /dev/null
+++ b/knexfile.js
@@ -0,0 +1,5 @@
+require('ts-node').register();
+const {Knex} = require('./src/config');
+// Update with your config settings.
+
+module.exports = Knex.config;
diff --git a/migrations/initial.ts b/migrations/initial.ts
new file mode 100644
index 0000000..1040564
--- /dev/null
+++ b/migrations/initial.ts
@@ -0,0 +1,52 @@
+import Knex from 'knex';
+
+export async function up(knex:Knex) {
+ 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("contents",(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("thumbnail").nullable();
+ b.json("additional").nullable();
+ b.timestamps();
+ b.index("content_type","content_type_index");
+ });
+ await knex.schema.createTable("tags", (b)=>{
+ b.string("name").primary();
+ b.text("description");
+ });
+ await knex.schema.createTable("content_tag_relation",(b)=>{
+ b.integer("content_id").unsigned().notNullable();
+ b.string("tag_name").notNullable();
+ b.foreign("content_id").references("contents.id");
+ b.foreign("tag_name").references("tags.name");
+ b.primary(["content_id","tag_name"]);
+ });
+ await knex.schema.createTable("permissions",b=>{
+ b.integer('username').unsigned().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.');
+ await knex.schema.dropTable("users");
+ await knex.schema.dropTable("contents");
+ await knex.schema.dropTable("tags");
+ await knex.schema.dropTable("content_tag_relation");
+ await knex.schema.dropTable("permissions");
+};
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..cfea48e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "followed",
+ "version": "1.0.0",
+ "description": "",
+ "main": "server.ts",
+ "scripts": {
+ "test": "mocha",
+ "build:dev": "webpack --mode development",
+ "build:prod": "webpack --mode production",
+ "build:watch": "webpack --mode development -w",
+ "start": "ts-node src/server.ts",
+ "check-types": "tsc"
+ },
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "@material-ui/core": "^4.11.2",
+ "@material-ui/icons": "^4.11.2",
+ "knex": "^0.21.14",
+ "koa": "^2.13.0",
+ "koa-bodyparser": "^4.3.0",
+ "koa-router": "^10.0.0",
+ "natural-orderby": "^2.0.3",
+ "node-stream-zip": "^1.12.0",
+ "react": "^17.0.1",
+ "react-dom": "^17.0.1",
+ "sqlite3": "^5.0.0",
+ "ts-node": "^9.1.1"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.12.10",
+ "@babel/plugin-proposal-class-properties": "^7.12.1",
+ "@babel/plugin-proposal-object-rest-spread": "^7.12.1",
+ "@babel/preset-env": "^7.12.11",
+ "@babel/preset-react": "^7.12.10",
+ "@babel/preset-typescript": "^7.12.7",
+ "@types/knex": "^0.16.1",
+ "@types/koa": "^2.11.6",
+ "@types/koa-bodyparser": "^4.3.0",
+ "@types/koa-router": "^7.4.1",
+ "@types/node": "^14.14.16",
+ "@types/react": "^17.0.0",
+ "@types/react-dom": "^17.0.0",
+ "babel-core": "^6.26.3",
+ "babel-loader": "^8.2.2",
+ "css-loader": "^5.0.1",
+ "mini-css-extract-plugin": "^1.3.3",
+ "style-loader": "^2.0.0",
+ "typescript": "^4.1.3",
+ "webpack": "^5.11.0",
+ "webpack-cli": "^4.2.0",
+ "webpack-dev-server": "^3.11.0"
+ }
+}
diff --git a/settings.json b/settings.json
new file mode 100644
index 0000000..ef5107b
--- /dev/null
+++ b/settings.json
@@ -0,0 +1,3 @@
+{
+ "path":["data"]
+}
\ No newline at end of file
diff --git a/src/client/css/style.css b/src/client/css/style.css
new file mode 100644
index 0000000..257cfc8
--- /dev/null
+++ b/src/client/css/style.css
@@ -0,0 +1,9 @@
+body {
+ margin: 0;
+ padding: 0;
+ font-family: sans-serif;
+}
+
+h1 {
+ text-decoration: underline;
+}
\ No newline at end of file
diff --git a/src/client/js/app.tsx b/src/client/js/app.tsx
new file mode 100644
index 0000000..53cc3fa
--- /dev/null
+++ b/src/client/js/app.tsx
@@ -0,0 +1,11 @@
+import hello from './hello'
+import React from 'react';
+import ReactDom from 'react-dom';
+import {Headline} from './test';
+import style from '../css/style.css';
+hello();
+
+ReactDom.render(
+ ,
+ document.getElementById("root")
+)
\ No newline at end of file
diff --git a/src/client/js/hello.ts b/src/client/js/hello.ts
new file mode 100644
index 0000000..480562d
--- /dev/null
+++ b/src/client/js/hello.ts
@@ -0,0 +1,4 @@
+export default function (){
+ console.log("hello");
+ console.log("???");
+};
\ No newline at end of file
diff --git a/src/client/js/test.tsx b/src/client/js/test.tsx
new file mode 100644
index 0000000..20689d1
--- /dev/null
+++ b/src/client/js/test.tsx
@@ -0,0 +1,88 @@
+import ReactDom from 'react-dom';
+import React, { useState } from 'react';
+import Drawer from '@material-ui/core/Drawer';
+import { Button, Divider, IconButton, List, ListItem } from '@material-ui/core';
+import { makeStyles, Theme, useTheme } from '@material-ui/core/styles';
+import {ChevronLeft, ChevronRight} from '@material-ui/icons';
+
+const drawerWidth = 240;
+
+const useStyles = makeStyles((theme: Theme) => ({
+ root: {
+ display: 'flex'
+ },
+ appBar: {
+ transition: theme.transitions.create(['width', 'margin'], {
+ easing: theme.transitions.easing.sharp,
+ duration: theme.transitions.duration.leavingScreen
+ })
+ },
+ appBarShift: {
+ width: `calc(100% - ${drawerWidth}px)`,
+ marginLeft: drawerWidth,
+ transition: theme.transitions.create(['margin', 'width'], {
+ easing: theme.transitions.easing.easeOut,
+ duration: theme.transitions.duration.enteringScreen,
+ }),
+ },
+ drawer: {
+ width: drawerWidth,
+ flexShrink: 0,
+ },
+ drawerPaper: {
+ width: drawerWidth,
+ },
+ drawerHeader: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: theme.spacing(0, 1),
+ // necessary for content to be below app bar
+ ...theme.mixins.toolbar,
+ justifyContent: 'flex-end',
+ },
+ content: {
+ flexGrow: 1,
+ padding: theme.spacing(3),
+ transition: theme.transitions.create('margin', {
+ easing: theme.transitions.easing.sharp,
+ duration: theme.transitions.duration.leavingScreen,
+ }),
+ marginLeft: -drawerWidth,
+ },
+ contentShift: {
+ transition: theme.transitions.create('margin', {
+ easing: theme.transitions.easing.easeOut,
+ duration: theme.transitions.duration.enteringScreen,
+ }),
+ marginLeft: 0,
+ },
+}));
+
+export const Headline = () => {
+ const [v, setv] = useState(false);
+ const classes = useStyles();
+ const theme = useTheme();
+
+ return (
+
+
+ setv(false)}>
+ {theme.direction === "ltr" ? : }
+
+
+
+
+
+
+ NO
+
+
+
+
+ aaa{`a${v} ${classes.content}`}aaa
+
+
+
);
+};
+
+export default Headline;
\ No newline at end of file
diff --git a/src/client/js/util.ts b/src/client/js/util.ts
new file mode 100644
index 0000000..57e3c6b
--- /dev/null
+++ b/src/client/js/util.ts
@@ -0,0 +1,14 @@
+
+type Representable = string|number|boolean;
+
+type ToQueryStringA = {
+ [name:string]:Representable|Representable[]
+};
+
+export const toQueryString = (obj:ToQueryStringA)=> {
+ return Object.entries(obj).map(e =>
+ e[1] instanceof Array
+ ? e[1].map(f=>`${e[0]}[]=${encodeURIComponent(f)}`).join('&')
+ : `${e[0]}=${encodeURIComponent(e[1])}`)
+ .join('&');
+}
\ No newline at end of file
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..435a4a6
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,16 @@
+export namespace Knex {
+ export const config = {
+ development: {
+ client: 'sqlite3',
+ connection: {
+ filename: './devdb.sqlite3'
+ }
+ },
+ production: {
+ client: 'sqlite3',
+ connection: {
+ database: './db.sqlite3',
+ },
+ }
+ };
+}
diff --git a/src/database.ts b/src/database.ts
new file mode 100644
index 0000000..73e5186
--- /dev/null
+++ b/src/database.ts
@@ -0,0 +1,28 @@
+import { existsSync } from 'fs';
+import Knex from 'knex';
+
+export async function connectDB(){
+ const config = require('./../knexfile');
+ const env = process.env.NODE_ENV || 'development';
+ const knex = Knex(config[env]);
+ let tries = 0;
+ for(;;){
+ try{
+ console.log("try to connect db");
+ await knex.raw('select 1 + 1;');
+ console.log("connect success");
+ }
+ catch(err){
+ if(tries < 3){
+ tries++;
+ console.error(`connection fail ${err} retry...`);
+ continue;
+ }
+ else{
+ throw err;
+ }
+ }
+ break;
+ }
+ return knex;
+}
\ No newline at end of file
diff --git a/src/db/contents.ts b/src/db/contents.ts
new file mode 100644
index 0000000..51b4ad1
--- /dev/null
+++ b/src/db/contents.ts
@@ -0,0 +1,143 @@
+import { Content, ContentContent, ContentAccessor, QueryListOption } from '../model/contents';
+import Knex from 'knex';
+import {createKnexTagController} from './tag';
+import { TagAccessor } from '../model/tag';
+
+type DBTagContentRelation = {
+ content_id:number,
+ tag_name:string
+}
+
+class KnexContentsAccessor implements ContentAccessor{
+ knex : Knex;
+ tagController: TagAccessor;
+ constructor(knex : Knex){
+ this.knex = knex;
+ this.tagController = createKnexTagController(knex);
+ }
+ async add(c: ContentContent){
+ const {tags,additional, ...rest} = c;
+ const id_lst = await this.knex.insert({
+ additional:JSON.stringify(additional),
+ ...rest
+ }).into('contents');
+ const id = id_lst[0];
+ for (const it of tags) {
+ this.tagController.addTag({name:it});
+ }
+ if(tags.length > 0){
+ await this.knex.insert(
+ tags.map(x=>({content_id:id,tag_name:x}))
+ ).into("content_tag_relation");
+ }
+ return id;
+ };
+ async del(id:number) {
+ if (await this.findById(id) !== undefined){
+ await this.knex.delete().from("contents").where({id:id});
+ return true;
+ }
+ return false;
+ };
+ async findById(id:number,tagload?:boolean){
+ const s:Content[] = await this.knex.select("*").from("contents").where({id:id});
+ if(s.length === 0) return undefined;
+ const first = s[0];
+ first.additional = JSON.parse((first.additional as unknown) as string)
+ first['tags'] = [];
+ if(tagload === true){
+ const tags : DBTagContentRelation[] = await this.knex.select("*")
+ .from("content_tag_relation").where({content_id:first.id});
+ first.tags = tags.map(x=>x.tag_name);
+ }
+ return first;
+ };
+ async findList(option?:QueryListOption){
+ option = option || {};
+ const allow_tag = option.allow_tag || [];
+ const eager_loading = typeof option.eager_loading === "undefined" || option.eager_loading;
+ const limit = option.limit || 20;
+ const use_offset = option.use_offset || false;
+ const offset = option.offset || 0;
+ const word = option.word;
+ const cursor = option.cursor;
+
+ const buildquery = ()=>{
+ let query = this.knex.select("*");
+ if(allow_tag.length > 0){
+ query = query.from("content_tag_relation").innerJoin("contents","content_tag_relation.content_id","contents.id");
+ for(const tag of allow_tag){
+ query = query.where({tag_name:tag});
+ }
+ }
+ else{
+ query = query.from("contents");
+ }
+ if(word !== undefined){
+ query = query.where('title','like',`%${word}%`);
+ }
+ if(use_offset){
+ query = query.offset(offset);
+ }
+ else{
+ if(cursor !== undefined){
+ query = query.where('id','<',cursor);
+ }
+ }
+ query = query.limit(limit);
+ return query;
+ }
+ let query = buildquery();
+ //console.log(query.toSQL());
+ let result:Content[] = await query;
+ for(let i of result){
+ i.additional = JSON.parse((i.additional as unknown) as string);
+ }
+ if(eager_loading){
+ let idmap: {[index:number]:Content} = {};
+ for(const r of result){
+ idmap[r.id] = r;
+ r.tags = [];
+ }
+ let subquery = buildquery();
+ let tagresult:{id:number,tag_name:string}[] = await this.knex.select("id","content_tag_relation.tag_name").from(subquery)
+ .innerJoin("content_tag_relation","content_tag_relation.content_id","id");
+ for(const {id,tag_name} of tagresult){
+ idmap[id].tags.push(tag_name);
+ }
+ }
+ return result;
+ };
+ async findListByBasePath(path:string):Promise{
+ let results:Content[] = await this.knex.select("*").from("contents").where({basepath:path});
+ results.forEach(e => {
+ e.additional = JSON.parse((e.additional as unknown) as string);
+ });
+ return results;
+ }
+ async update(c:Partial & { id:number }){
+ const {id,tags,...rest} = c;
+ if (await this.findById(id) !== undefined){
+ await this.knex.update(rest).where({id: id}).from("contents");
+ return true;
+ }
+ return false;
+ }
+ async addTag(c: Content,tag_name:string){
+ if (c.tags.includes(tag_name)) return false;
+ this.tagController.addTag({name:tag_name});
+ await this.knex.insert({tag_name: tag_name, content_id: c.id})
+ .into("content_tag_relation");
+ c.tags.push(tag_name);
+ return true;
+ }
+ async delTag(c: Content,tag_name:string){
+ if (c.tags.includes(tag_name)) return false;
+ await this.knex.delete().where({tag_name: tag_name,content_id: c.id}).from("content_tag_relation");
+ c.tags.push(tag_name);
+ return true;
+ }
+}
+export const createKnexContentsAccessor = (knex:Knex): ContentAccessor=>{
+ return new KnexContentsAccessor(knex);
+}
\ No newline at end of file
diff --git a/src/db/mod.ts b/src/db/mod.ts
new file mode 100644
index 0000000..c36784f
--- /dev/null
+++ b/src/db/mod.ts
@@ -0,0 +1,3 @@
+export * from './contents';
+export * from './tag';
+export * from './user';
\ No newline at end of file
diff --git a/src/db/tag.ts b/src/db/tag.ts
new file mode 100644
index 0000000..e992c9c
--- /dev/null
+++ b/src/db/tag.ts
@@ -0,0 +1,50 @@
+import {Tag, TagAccessor} from '../model/tag';
+import Knex from 'knex';
+
+type DBTags = {
+ name: string,
+ description?: string
+}
+class KnexTagAccessor implements TagAccessor{
+ knex:Knex
+ constructor(knex:Knex){
+ this.knex = knex;
+ }
+ async getTagAllList(onlyname?:boolean){
+ onlyname = onlyname || false;
+ const t:DBTags[] = await this.knex.select(onlyname ? "*" : "name").from("tags")
+ return t;
+ }
+ async getTagByName(name:string){
+ const t:DBTags[] = await this.knex.select('*').from("tags").where({name: name});
+ if(t.length === 0) return undefined;
+ return t[0];
+ }
+ async addTag(tag: Tag){
+ if(await this.getTagByName(tag.name) === undefined){
+ await this.knex.insert({
+ name:tag.name,
+ description:tag.description === undefined ? "" : tag.description
+ }).into("tags");
+ return true;
+ }
+ return false;
+ }
+ async delTag(name:string){
+ if(await this.getTagByName(name) !== undefined){
+ await this.knex.delete().where({name:name}).from("tags");
+ return true;
+ }
+ return false;
+ }
+ async updateTag(name:string,desc:string){
+ if(await this.getTagByName(name) !== undefined){
+ await this.knex.update({description:desc}).where({name:name}).from("tags");
+ return true;
+ }
+ return false;
+ }
+};
+export const createKnexTagController = (knex:Knex):TagAccessor=>{
+ return new KnexTagAccessor(knex);
+}
\ No newline at end of file
diff --git a/src/db/user.ts b/src/db/user.ts
new file mode 100644
index 0000000..1a2e529
--- /dev/null
+++ b/src/db/user.ts
@@ -0,0 +1,82 @@
+import Knex from 'knex';
+import {IUser,UserCreateInput, UserAccessor, Password} from '../model/user';
+
+type PermissionTable={
+ username:string,
+ name:string
+};
+type DBUser = {
+ username : string,
+ password_hash: string,
+ password_salt: string
+}
+class KnexUser implements IUser{
+ private knex: Knex;
+ readonly username: string;
+ readonly password: Password;
+
+ constructor(username: string, pw: Password,knex:Knex){
+ this.username = username;
+ this.password = pw;
+ this.knex = knex;
+ }
+ async reset_password(password: string){
+ this.password.set_password(password);
+ await this.knex.from("users")
+ .where({username:this.username})
+ .update({password_hash:this.password.hash,password_salt:this.password.salt});
+ }
+ async get_permissions(){
+ let b = (await this.knex.select('*').from("permissions")
+ .where({username : this.username})) as PermissionTable[];
+ return b.map(x=>x.name);
+ }
+ async add(name: string) {
+ if(!(await this.get_permissions()).includes(name)){
+ const r = await this.knex.insert({
+ username: this.username,
+ name: name
+ }).into("permissions");
+ return true;
+ }
+ return false;
+ }
+ async remove(name: string) {
+ const r = await this.knex
+ .from("permissions")
+ .where({
+ username:this.username, name:name
+ }).delete();
+ return r !== 0;
+ }
+}
+
+export const createKnexUserController = (knex: Knex):UserAccessor=>{
+ const createUserKnex = async (input:UserCreateInput)=>{
+ if(undefined !== (await findUserKenx(input.username))){
+ return undefined;
+ }
+ const user = new KnexUser(input.username,new Password(input.password),knex);
+ await knex.insert({
+ username: user.username,
+ password_hash: user.password.hash,
+ password_salt: user.password.salt}).into("users");
+ return user;
+ };
+ const findUserKenx = async (id:string)=>{
+ let user:DBUser[] = await knex.select("*").from("users").where({username:id});
+ if(user.length == 0) return undefined;
+ const first = user[0];
+ return new KnexUser(first.username,
+ new Password({hash: first.password_hash, salt: first.password_salt}), knex);
+ }
+ const delUserKnex = async (id:string) => {
+ let r = await knex.delete().from("users").where({username:id});
+ return r===0;
+ }
+ return {
+ createUser: createUserKnex,
+ findUser: findUserKenx,
+ delUser: delUserKnex,
+ };
+}
\ No newline at end of file
diff --git a/src/diff.ts b/src/diff.ts
new file mode 100644
index 0000000..ad1adbd
--- /dev/null
+++ b/src/diff.ts
@@ -0,0 +1,69 @@
+import { watch } from 'fs';
+import { promises } from 'fs';
+
+const readdir = promises.readdir;
+
+type FileDiff = {
+ name: string,
+ desc: string
+};
+
+export class Watcher{
+ private _path:string;
+ private _added: FileDiff[];
+ private _deleted: FileDiff[];
+ constructor(path:string){
+ this._path = path;
+ this._added =[];
+ this._deleted =[]
+ }
+ public get added() : FileDiff[] {
+ return this._added;
+ }
+ /*public set added(diff : FileDiff[]) {
+ this._added = diff;
+ }*/
+ public get deleted(): FileDiff[]{
+ return this._deleted;
+ }
+ /*public set deleted(diff : FileDiff[]){
+ this._deleted = diff;
+ }*/
+ public get path(){
+ return this._path;
+ }
+
+ async setup(initial_filenames:string[]){
+ const cur = (await readdir(this._path,{
+ encoding:"utf8",
+ withFileTypes: true,
+ })).filter(x=>x.isFile).map(x=>x.name);
+ let added = cur.filter(x => !initial_filenames.includes(x));
+ let deleted = initial_filenames.filter(x=>!cur.includes(x));
+ this._added = added.map(x=>{return {name:x,desc:""}});
+ this._deleted = deleted.map(x=>{return {name:x,desc:""}});
+ watch(this._path,{persistent: true, recursive:false},async (eventType,filename)=>{
+ if(eventType === "rename"){
+ const cur = (await readdir(this._path,{
+ encoding:"utf8",
+ withFileTypes: true,
+ })).filter(x=>x.isFile).map(x=>x.name);
+ if(cur.includes(filename)){
+ this._added.push({name:filename,desc:""});
+ }
+ else{
+ if(this._added.map(x=>x.name).includes(filename)){
+ this._added = this._added.filter(x=> x.name !== filename);
+ }
+ else {
+ this._deleted.push({name:filename,desc:""});
+ }
+ }
+ }
+ });
+ }
+}
+
+export class DiffWatcher{
+ Watchers: {[basepath:string]:Watcher} = {};
+}
\ No newline at end of file
diff --git a/src/model/contents.ts b/src/model/contents.ts
new file mode 100644
index 0000000..34cf15f
--- /dev/null
+++ b/src/model/contents.ts
@@ -0,0 +1,112 @@
+import {TagAccessor} from './tag';
+import {check_type} from './../util/type_check'
+
+
+export interface ContentContent{
+ title : string,
+ content_type : string,
+ basepath : string,
+ filename : string,
+ thumbnail? : string,
+ additional : object,
+ tags : string[],//eager loading
+}
+
+export const MetaContentContent = {
+ title : "string",
+ content_type : "string",
+ basepath : "string",
+ filename : "string",
+ additional : "object",
+ tags : "string[]",
+}
+
+export const isContentContent = (c : any):c is ContentContent =>{
+ return check_type(c,MetaContentContent);
+}
+
+export interface Content extends ContentContent{
+ readonly id: number;
+};
+
+export const isContent = (c: any):c is Content =>{
+ if('id' in c && typeof c['id'] === "number"){
+ const {id, ...rest} = c;
+ return isContentContent(rest);
+ }
+ return false;
+}
+
+export interface 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 contents
+ */
+ cursor?:number,
+ /**
+ * offset of contents
+ */
+ offset?:number,
+ /**
+ * tag eager loading
+ * @default true
+ */
+ eager_loading?:boolean,
+ /**
+ *
+ */
+ content_type?:string,
+}
+
+export interface ContentAccessor{
+ /**
+ * find list by option
+ * @returns content list
+ */
+ findList: (option?:QueryListOption)=>Promise,
+ /**
+ * @returns content if exist, otherwise undefined
+ */
+ findById: (id:number,tagload?:boolean)=> Promise,
+ /**
+ *
+ */
+ findListByBasePath:(basepath: string)=>Promise;
+ /**
+ * update content except tag.
+ */
+ update:(c:Partial & { id:number })=>Promise;
+ /**
+ * add content
+ */
+ add:(c:ContentContent)=>Promise;
+ /**
+ * delete content
+ * @returns if it exists, return true.
+ */
+ del:(id:number)=>Promise;
+ /**
+ * @param c Valid Content
+ * @param tagname tag name to add
+ * @returns if success, return true
+ */
+ addTag:(c:Content,tag_name:string)=>Promise;
+ /**
+ * @returns if success, return true
+ */
+ delTag:(c:Content,tag_name:string)=>Promise;
+};
\ No newline at end of file
diff --git a/src/model/mod.ts b/src/model/mod.ts
new file mode 100644
index 0000000..c36784f
--- /dev/null
+++ b/src/model/mod.ts
@@ -0,0 +1,3 @@
+export * from './contents';
+export * from './tag';
+export * from './user';
\ No newline at end of file
diff --git a/src/model/tag.ts b/src/model/tag.ts
new file mode 100644
index 0000000..5071e8d
--- /dev/null
+++ b/src/model/tag.ts
@@ -0,0 +1,12 @@
+export interface Tag{
+ readonly name: string,
+ description?: string
+}
+
+export interface TagAccessor{
+ getTagAllList: (onlyname?:boolean)=> Promise
+ getTagByName: (name:string)=>Promise,
+ addTag: (tag:Tag)=>Promise,
+ delTag: (name:string) => Promise,
+ updateTag: (name:string,tag:string) => Promise
+}
diff --git a/src/model/user.ts b/src/model/user.ts
new file mode 100644
index 0000000..02b3f4c
--- /dev/null
+++ b/src/model/user.ts
@@ -0,0 +1,80 @@
+import { createHmac, randomBytes } from 'crypto';
+
+function hashForPassword(salt: string,password:string){
+ return createHmac('sha256', salt).update(password).digest('hex')
+}
+function createPasswordHashAndSalt(password: string):{salt:string,hash:string}{
+ const secret = randomBytes(32).toString('hex');
+ return {
+ salt: secret,
+ hash: hashForPassword(secret,password)
+ };
+}
+
+export class Password{
+ private _salt:string;
+ private _hash:string;
+ constructor(pw : string|{salt:string,hash:string}){
+ const {salt,hash} = typeof pw === "string" ? createPasswordHashAndSalt(pw) : pw;
+ this._hash = hash;
+ this._salt = salt;
+ }
+ set_password(password: string){
+ const {salt,hash} = createPasswordHashAndSalt(password);
+ this._hash = hash;
+ this._salt = salt;
+ }
+ check_password(password: string):boolean{
+ return this._hash === hashForPassword(this._salt,password);
+ }
+ get salt(){return this._salt;}
+ get hash(){return this._hash;}
+}
+
+export interface UserCreateInput{
+ username: string,
+ password: string
+}
+
+export interface IUser{
+ readonly username : string;
+ readonly password : Password;
+ /**
+ * return user's permission list.
+ */
+ get_permissions():Promise;
+ /**
+ * add permission
+ * @param name permission name to add
+ * @returns if `name` doesn't exist, return true
+ */
+ add(name :string):Promise;
+ /**
+ * remove permission
+ * @param name permission name to remove
+ * @returns if `name` exist, return true
+ */
+ remove(name :string):Promise;
+ /**
+ * reset password.
+ * @param password password to set
+ */
+ reset_password(password: string):Promise;
+};
+
+export interface UserAccessor{
+ /**
+ * create user
+ * @returns if user exist, return undefined
+ */
+ createUser: (input :UserCreateInput)=> Promise,
+ /**
+ * find user
+ */
+ findUser: (username: string)=> Promise,
+ /**
+ * remove user
+ * @returns if user exist, true
+ */
+ delUser: (username: string)=>Promise
+};
\ No newline at end of file
diff --git a/src/render.ts b/src/render.ts
new file mode 100644
index 0000000..3a076b3
--- /dev/null
+++ b/src/render.ts
@@ -0,0 +1,99 @@
+import {Context} from 'koa';
+import {promises, createReadStream} from "fs";
+import {createReadStreamFromZip, entriesByNaturalOrder, readZip} from "./util/ziputil";
+
+function since_last_modified(ctx: Context, last_modified: Date): boolean{
+ const con = ctx.get("If-Modified-Since");
+ if(con === "") return false;
+ const mdate = new Date(con);
+ if(last_modified > mdate) return false;
+ ctx.status = 304;
+ return true;
+}
+
+export async function renderZipImage(ctx: Context,path : string, page:number){
+ const image_ext = ['gif', 'png', 'jpeg', 'bmp', 'webp', 'jpg'];
+ let zip = await readZip(path);
+ const entries = entriesByNaturalOrder(zip).filter(x=>{
+ const ext = x.name.split('.').pop();
+ return ext !== undefined && image_ext.includes(ext);
+ });
+ if(0 <= page && page < entries.length){
+ const entry = entries[page];
+ const last_modified = new Date(entry.time);
+ if(since_last_modified(ctx,last_modified)){
+ return;
+ }
+ const read_stream = (await createReadStreamFromZip(zip,entry));
+ read_stream.on('close',()=>zip.close());
+ ctx.body = read_stream;
+ ctx.response.length = entry.size;
+ console.log(`${entry.name}'s ${page}:${entry.size}`);
+ ctx.response.type = entry.name.split(".").pop() as string;
+ ctx.status = 200;
+ ctx.set('Date', new Date().toUTCString());
+ ctx.set("Last-Modified",last_modified.toUTCString());
+ }
+ else{
+ ctx.status = 404;
+ }
+}
+export async function renderImage(ctx: Context,path : string){
+ const ext = path.trim().split('.').pop();
+ if(ext === undefined) {
+ ctx.status = 404;
+ return;
+ }
+ ctx.response.type = ext;
+ ctx.body = createReadStream(path);
+}
+
+export async function renderVideo(ctx: Context,path : string){
+ const ext = path.trim().split('.').pop();
+ if(ext === undefined) {
+ //ctx.status = 404;
+ console.error(`${path}:${ext}`)
+ return;
+ }
+ ctx.response.type = ext;
+ const range_text = ctx.request.get("range");
+ const stat = await promises.stat(path);
+ let start = 0;
+ let end = 0;
+ ctx.set('Last-Modified',(new Date(stat.mtime).toUTCString()));
+ ctx.set('Date', new Date().toUTCString());
+ ctx.set("Accept-Ranges", "bytes");
+ if(range_text === ''){
+ end = 1024*512;
+ end = Math.min(end,stat.size-1);
+ if(start > end){
+ ctx.status = 416;
+ return;
+ }
+ ctx.status = 200;
+ ctx.length = stat.size;
+ let stream = createReadStream(path);
+ ctx.body = stream;
+ }
+ else{
+ const m = range_text.match(/^bytes=(\d+)-(\d*)/);
+ if(m === null){
+ ctx.status = 416;
+ return;
+ }
+ start = parseInt(m[1]);
+ end = m[2].length > 0 ? parseInt(m[2]) : start + 1024*1024;
+ end = Math.min(end,stat.size-1);
+ if(start > end){
+ ctx.status = 416;
+ return;
+ }
+ ctx.status = 206;
+ ctx.length = end - start + 1;
+ ctx.response.set("Content-Range",`bytes ${start}-${end}/${stat.size}`);
+ ctx.body = createReadStream(path,{
+ start:start,
+ end:end
+ });//inclusive range.
+ }
+}
\ No newline at end of file
diff --git a/src/route/contents.ts b/src/route/contents.ts
new file mode 100644
index 0000000..cf06b82
--- /dev/null
+++ b/src/route/contents.ts
@@ -0,0 +1,133 @@
+import { Context, Next } from 'koa';
+import Router from 'koa-router';
+import {ContentAccessor, isContentContent} from './../model/contents';
+import {QueryListOption} from './../model/contents';
+import {ParseQueryNumber, ParseQueryArray, ParseQueryBoolean} from './util'
+import {sendError} from './error_handler';
+
+const ContentIDHandler = (controller: ContentAccessor) => async (ctx: Context,next: Next)=>{
+ const num = Number.parseInt(ctx.params['num']);
+ if (num === NaN){
+ return await next();
+ }
+ let content = await controller.findById(num,true);
+ if (content == undefined){
+ sendError(404,"content does not exist.");
+ return;
+ }
+ ctx.body = content;
+ ctx.type = 'json';
+ return await next();
+};
+const ContentTagIDHandler = (controller: ContentAccessor) => async (ctx: Context,next: Next)=>{
+ const num = Number.parseInt(ctx.params['num']);
+ if (num === NaN){
+ return await next();
+ }
+ let content = await controller.findById(num,true);
+ if (content == undefined){
+ sendError(404,"content does not exist.");
+ return;
+ }
+ ctx.body = content.tags || [];
+ ctx.type = 'json';
+ return await next();
+};
+const ContentQueryHandler = (controller : ContentAccessor) => async (ctx: Context,next: Next)=>{
+ const limit = ParseQueryNumber(ctx.query['limit']);
+ const cursor = ParseQueryNumber(ctx.query['cursor']);
+ const word: string|undefined = ctx.query['word'];
+ const offset = ParseQueryNumber(ctx.query['offset']);
+ if(limit === NaN || cursor === NaN || offset === NaN){
+ sendError(400,"parameter limit, cursor or offset is not a number");
+ }
+ const allow_tag = ParseQueryArray(ctx.query['allow_tag[]']);
+ let [ok,use_offset] = ParseQueryBoolean(ctx.query['use_offset']);
+ if(!ok){
+ sendError(400,"use_offset must be true or false.");
+ return;
+ }
+ const option :QueryListOption = {
+ limit: limit,
+ allow_tag: allow_tag,
+ word: word,
+ cursor: cursor,
+ eager_loading: true,
+ offset: offset,
+ use_offset: use_offset
+ };
+ let content = await controller.findList(option);
+ ctx.body = content;
+ ctx.type = 'json';
+}
+const CreateContentHandler = (controller : ContentAccessor) => async (ctx: Context, next: Next) => {
+ const content_desc = ctx.request.body;
+ if(!isContentContent(content_desc)){
+ sendError(400,"it is not a valid format");
+ return;
+ }
+ const id = await controller.add(content_desc);
+ ctx.body = {"ret":id};
+ ctx.type = 'json';
+};
+const AddTagHandler = (controller: ContentAccessor)=>async (ctx: Context, next: Next)=>{
+ let tag_name = ctx.params['tag'];
+ const num = Number.parseInt(ctx.params['num']);
+ if (num === NaN){
+ return await next();
+ }
+ if(typeof tag_name === undefined){
+ sendError(400,"??? Unreachable");
+ }
+ tag_name = String(tag_name);
+ const c = await controller.findById(num);
+ if(c === undefined){
+ sendError(404);
+ return;
+ }
+ const r = await controller.addTag(c,tag_name);
+ ctx.body = {ret:r}
+ ctx.type = 'json';
+};
+const DelTagHandler = (controller: ContentAccessor)=>async (ctx: Context, next: Next)=>{
+ let tag_name = ctx.params['tag'];
+ const num = Number.parseInt(ctx.params['num']);
+ if (num === NaN){
+ return await next();
+ }
+ if(typeof tag_name === undefined){
+ sendError(400,"?? Unreachable");
+ }
+ tag_name = String(tag_name);
+ const c = await controller.findById(num);
+ if(c === undefined){
+ sendError(404);
+ return;
+ }
+ const r = await controller.delTag(c,tag_name);
+ ctx.body = {ret:r}
+ ctx.type = 'json';
+}
+const DeleteContentHandler = (controller : ContentAccessor) => async (ctx: Context, next: Next) => {
+ const num = Number.parseInt(ctx.params['num']);
+ if (num === NaN){
+ return await next();
+ }
+ const r = await controller.del(num);
+ ctx.body = {"ret":r};
+ ctx.type = 'json';
+};
+export const getContentRouter = (controller: ContentAccessor)=>{
+ const ret = new Router();
+ ret.get("/search",ContentQueryHandler(controller));
+ ret.get("/:num",ContentIDHandler(controller));
+ ret.post("/",CreateContentHandler(controller));
+ ret.get("/:num/tags",ContentTagIDHandler(controller));
+ ret.post("/:num/tags/:tag",AddTagHandler(controller));
+ ret.del("/:num/tags/:tag",DelTagHandler(controller));
+ ret.del("/:num",DeleteContentHandler(controller));
+ //ret.get("/");
+ return ret;
+}
+
+export default getContentRouter;
\ No newline at end of file
diff --git a/src/route/error_handler.ts b/src/route/error_handler.ts
new file mode 100644
index 0000000..fa07782
--- /dev/null
+++ b/src/route/error_handler.ts
@@ -0,0 +1,50 @@
+import {Context, Next} from 'koa';
+
+interface ErrorFormat {
+ code: number,
+ message: string,
+ detail?: string
+}
+
+export class ClientRequestError implements Error{
+ name: string;
+ message: string;
+ stack?: string | undefined;
+ code: number;
+
+ constructor(code : number,message: string){
+ this.name = "client request error";
+ this.message = message;
+ this.code = code;
+ }
+}
+
+const code_to_message_table:{[key:number]:string|undefined} = {
+ 400:"BadRequest",
+ 404:"NotFound"
+}
+
+export const error_handler = async (ctx:Context,next: Next)=>{
+ try {
+ await next();
+ } catch (err) {
+ if(err instanceof ClientRequestError){
+ const body : ErrorFormat= {
+ code: err.code,
+ message: code_to_message_table[err.code] || "",
+ detail: err.message
+ }
+ ctx.status = err.code;
+ ctx.body = body;
+ }
+ else{
+ throw err;
+ }
+ }
+}
+
+export const sendError = (code:number,message?:string) =>{
+ throw new ClientRequestError(code,message || "");
+}
+
+export default error_handler;
\ No newline at end of file
diff --git a/src/route/util.ts b/src/route/util.ts
new file mode 100644
index 0000000..0116694
--- /dev/null
+++ b/src/route/util.ts
@@ -0,0 +1,22 @@
+
+
+export function ParseQueryNumber(s: string|undefined): number| undefined{
+ if(s === undefined) return undefined;
+ else return Number.parseInt(s);
+}
+export function ParseQueryArray(s: string[]|string|undefined){
+ s = s || [];
+ return s instanceof Array ? s : [s];
+}
+export function ParseQueryBoolean(s: string|undefined): [boolean,boolean|undefined]{
+ let value:boolean|undefined;
+ if(s === "true")
+ value = true;
+ else if(s === "false")
+ value = false;
+ else if(s === undefined)
+ value = undefined;
+ else
+ return [false,undefined]
+ return [true,value]
+}
\ No newline at end of file
diff --git a/src/server.ts b/src/server.ts
new file mode 100644
index 0000000..11091fa
--- /dev/null
+++ b/src/server.ts
@@ -0,0 +1,76 @@
+import Koa from 'koa';
+import Router from 'koa-router';
+
+import {get_setting} from './setting';
+import {connectDB} from './database';
+import {Watcher} from './diff'
+import {renderImage, renderVideo, renderZipImage} from './render';
+import { createReadStream, readFileSync } from 'fs';
+import getContentRouter from './route/contents';
+import { createKnexContentsAccessor } from './db/contents';
+import bodyparser from 'koa-bodyparser';
+import {error_handler} from './route/error_handler';
+//let Koa = require("koa");
+async function main(){
+ let app = new Koa();
+ app.use(bodyparser());
+ app.use(error_handler);
+ let router = new Router();
+
+ let settings = get_setting();
+
+ let db = await connectDB();
+ let watcher = new Watcher(settings.path[0]);
+ await watcher.setup([]);
+ console.log(settings);
+ router.get('/', async (ctx,next)=>{
+ ctx.type = "html";
+ ctx.body = readFileSync("index.html");
+ });
+ router.get('/dist/css/style.css',async (ctx,next)=>{
+ ctx.type = "css";
+ ctx.body = createReadStream("dist/css/style.css");
+ });
+ router.get('/dist/js/bundle.js',async (ctx,next)=>{
+ ctx.type = "js";
+ ctx.body = createReadStream("dist/js/bundle.js");
+ });
+ router.get('/get'
+ ,async (ctx,next)=>{
+ ctx.body = ctx.query;
+ console.log(JSON.stringify(ctx.query));
+ await next();
+ }
+ );
+ let content_router = getContentRouter(createKnexContentsAccessor(db));
+ router.use('/content',content_router.routes());
+ router.get('/ss.mp4',async (ctx,next)=>{
+ /*for(let i in ctx.header){
+ if(i !== undefined)
+ console.log(i,ctx.get(i));
+ }*/
+ await renderVideo(ctx,"testdata/video_test.mp4");
+ await next();
+ });
+ router.get('/image/:number',async (ctx,next)=>{
+ let page = ctx.params.number;
+ await renderZipImage(ctx,"testdata/test_zip.zip",page);
+ ctx.set("cache-control","max-age=3600");
+ await next();
+ });
+
+ let mm_count=0;
+ app.use(async (ctx,next)=>{
+ console.log(`==========================${mm_count++}`);
+ console.log(`connect ${ctx.ip} : ${ctx.method} ${ctx.url}`);
+ await next();
+ //console.log(`404`);
+ });
+ app.use(router.routes());
+ app.use(router.allowedMethods());
+
+ console.log("log");
+ app.listen(3002);
+ return app;
+}
+main();
\ No newline at end of file
diff --git a/src/setting.ts b/src/setting.ts
new file mode 100644
index 0000000..f11d26e
--- /dev/null
+++ b/src/setting.ts
@@ -0,0 +1,15 @@
+import { readFileSync } from 'fs';
+
+export type Setting = {
+ path: string[]
+}
+let setting: null|Setting = null;
+export const read_setting_from_file = ()=>{
+ return JSON.parse(readFileSync("settings.json",{encoding:"utf8"})) as Setting;
+}
+export function get_setting():Setting{
+ if(setting === null){
+ setting = read_setting_from_file();
+ }
+ return setting;
+}
diff --git a/src/util/type_check.ts b/src/util/type_check.ts
new file mode 100644
index 0000000..b85f915
--- /dev/null
+++ b/src/util/type_check.ts
@@ -0,0 +1,16 @@
+export function check_type(obj: any,check_proto:Record):obj is T{
+ for (const it in check_proto) {
+ let defined = check_proto[it];
+ if(defined === undefined) return false;
+ defined = defined.trim();
+ if(defined.endsWith("[]")){
+ if(!(obj[it] instanceof Array)){
+ return false;
+ }
+ }
+ else if(defined !== typeof obj[it]){
+ return false;
+ }
+ }
+ return true;
+};
\ No newline at end of file
diff --git a/src/util/ziputil.ts b/src/util/ziputil.ts
new file mode 100644
index 0000000..3c54bd9
--- /dev/null
+++ b/src/util/ziputil.ts
@@ -0,0 +1,36 @@
+import StreamZip, { ZipEntry } from 'node-stream-zip';
+import {orderBy}from 'natural-orderby';
+
+export async function readZip(path : string):Promise{
+ return new Promise((resolve,reject)=>{
+ let zip = new StreamZip({
+ file:path,
+ storeEntries: true
+ });
+ zip.on('error',(err)=>{
+ console.error(`read zip file ${path}`);
+ reject(err);
+ });
+ zip.on('ready',()=>{
+ resolve(zip);
+ });
+ }
+ );
+}
+export function entriesByNaturalOrder(zip: StreamZip){
+ const entries = zip.entries();
+ const ret = orderBy(Object.values(entries),v=>v.name);
+ return ret;
+}
+export async function createReadStreamFromZip(zip:StreamZip,entry: ZipEntry):Promise{
+ return new Promise((resolve,reject)=>{
+ zip.stream(entry,(err, stream)=>{
+ if(stream !== undefined){
+ resolve(stream);
+ }
+ else{
+ reject(err);
+ }
+ });}
+ );
+}
\ No newline at end of file
diff --git a/test.ts b/test.ts
new file mode 100644
index 0000000..6c52fc4
--- /dev/null
+++ b/test.ts
@@ -0,0 +1,48 @@
+import StreamZip, { ZipEntry } from 'node-stream-zip';
+import {orderBy}from 'natural-orderby';
+import {readZip,entriesByNaturalOrder} from './src/util/ziputil';
+import {connectDB} from './src/database';
+
+import { createKnexUserController } from './src/db/user';
+import { createKnexContentsAccessor} from './src/db/contents';
+
+console.log("on");
+async function test_main1(){
+ let sz = await readZip("testdata/test_zip.zip");
+ let e = entriesByNaturalOrder(sz).filter(v=>v.name.split('.').pop() === "jpg");
+ console.log(e[0]);
+ console.log(e.map(v=>v.name));
+}
+async function test_main2(){
+ let db = await connectDB();
+ let user:{id:number}[] = await db.select('id').from('users');
+ console.log(user[0].id);
+}
+
+async function test_main3(){
+ let db = await connectDB();
+ let user_controller = createKnexUserController(db);
+ let bs = await user_controller.delUser("sss");
+ if(!bs) console.log("doesn't exist")
+ let retuser = await user_controller.createUser({username:"sss",password:"sss"});
+ let user = await user_controller.findUser("sss");
+ if(user !== undefined){
+ user.add("create");
+ console.log(user.username);
+ }
+}
+async function test_main4(){
+ let db = await connectDB();
+ const cntr = createKnexContentsAccessor(db);
+ await cntr.add({
+ title:"aaa",
+ basepath:"testdata",
+ content_type:"manga",
+ filename:"test_zip.zip",
+ additional:{comment:"aaab"},
+ tags:[]
+ });
+ const list = await cntr.findList();
+ console.log(list);
+}
+test_main4();
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..8773d4d
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,70 @@
+{
+ "compilerOptions": {
+ /* Visit https://aka.ms/tsconfig.json to read more about this file */
+
+ /* Basic Options */
+ // "incremental": true, /* Enable incremental compilation */
+ "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
+ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
+ "lib": ["DOM","ES6"], /* Specify library files to be included in the compilation. */
+ //"allowJs": true, /* Allow javascript files to be compiled. */
+ // "checkJs": true, /* Report errors in .js files. */
+ "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
+ // "declaration": true, /* Generates corresponding '.d.ts' file. */
+ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
+ // "sourceMap": true, /* Generates corresponding '.map' file. */
+ // "outFile": "./", /* Concatenate and emit output to single file. */
+ // "outDir": "./", /* Redirect output structure to the directory. */
+ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+ // "composite": true, /* Enable project compilation */
+ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
+ // "removeComments": true, /* Do not emit comments to output. */
+ // "noEmit": true, /* Do not emit outputs. */
+ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
+ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+
+ /* Strict Type-Checking Options */
+ "strict": true, /* Enable all strict type-checking options. */
+ "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
+ "strictNullChecks": true, /* Enable strict null checks. */
+ // "strictFunctionTypes": true, /* Enable strict checking of function types. */
+ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
+ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
+ "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
+ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
+
+ /* Additional Checks */
+ // "noUnusedLocals": true, /* Report errors on unused locals. */
+ // "noUnusedParameters": true, /* Report errors on unused parameters. */
+ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
+
+ /* Module Resolution Options */
+ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
+ // "typeRoots": [], /* List of folders to include type definitions from. */
+ // "types": [], /* Type declaration files to be included in compilation. */
+ "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
+ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
+ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
+
+ /* Source Map Options */
+ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
+ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+
+ /* Experimental Options */
+ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
+ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
+
+ /* Advanced Options */
+ "skipLibCheck": true, /* Skip type checking of declaration files. */
+ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
+ },
+ "include": ["src","./"]
+}
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000..75c6c1a
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,36 @@
+const path = require("path");
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+
+module.exports = ()=>{return {
+ entry: './src/client/js/app.tsx',
+ output: {
+ path: path.resolve(__dirname, 'dist/js'),
+ filename: 'bundle.js'
+ },
+ module: {
+ rules: [
+ {
+ test: /\.(js|ts|tsx)$/,
+ exclude: /node_modules/,
+ use: {
+ loader: 'babel-loader',
+ }
+ },
+ {
+ test: /\.css$/,
+ use: [MiniCssExtractPlugin.loader,'css-loader']
+ }
+ ]
+ },
+ plugins : [
+ new MiniCssExtractPlugin({
+ "filename":'../css/style.css'})
+ ],
+ resolve: {
+ extensions: ['.js','.css','.ts','.tsx']
+ },
+ devServer: {
+ historyApiFallback: true,
+ port: 3001
+ }
+};}
\ No newline at end of file