diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..54f358ef --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +fuse.ts +generate_translations.js +src/api_tests diff --git a/.gitignore b/.gitignore index bdd11a35..34454f3f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ test/data/result.json package-lock.json *.orig +src/shared/translations + diff --git a/fuse.ts b/fuse.ts index 416ce2e6..9de9d564 100644 --- a/fuse.ts +++ b/fuse.ts @@ -1,76 +1,82 @@ -import { CSSPlugin, FuseBox, FuseBoxOptions, Sparky } from "fuse-box"; -import path = require("path"); -import TsTransformClasscat from "ts-transform-classcat"; -import TsTransformInferno from "ts-transform-inferno"; -/** - * Some of FuseBoxOptions overrides by ts config (module, target, etc) - * https://fuse-box.org/page/working-with-targets - */ -let fuse: FuseBox; -const fuseOptions: FuseBoxOptions = { - homeDir: "./src", - output: "dist/$name.js", - sourceMaps: { inline: false, vendor: false }, - /** - * Custom TypeScript Transformers (compile Inferno tsx to ts) - */ - transformers: { - before: [TsTransformClasscat(), TsTransformInferno()] - } -}; -const fuseClientOptions: FuseBoxOptions = { - ...fuseOptions, - plugins: [ - /** - * https://fuse-box.org/page/css-resource-plugin - * Compile Sass {SassPlugin()} - * Make .css files modules-like (allow import them like modules) {CSSModules} - * Make .css files modules like and allow import it from node_modules too {CSSResourcePlugin} - * Use them all and bundle with {CSSPlugin} - * */ - CSSPlugin() - ] -}; -const fuseServerOptions: FuseBoxOptions = { - ...fuseOptions -}; -Sparky.task("clean", () => { - /**Clean distribute (dist) folder */ - Sparky.src("dist") - .clean("dist") - .exec(); -}); -Sparky.task("config", () => { - fuse = FuseBox.init(fuseOptions); - fuse.dev(); -}); -Sparky.task("test", ["&clean", "&config"], () => { - fuse.bundle("client/bundle").test("[**/**.test.tsx]", null); -}); -Sparky.task("client", () => { - fuse.opts = fuseClientOptions; - fuse - .bundle("client/bundle") - .target("browser@esnext") - .watch("client/**") - .hmr() - .instructions("> client/index.tsx"); -}); -Sparky.task("server", () => { - /**Workaround. Should be fixed */ - fuse.opts = fuseServerOptions; - fuse - .bundle("server/bundle") - .watch("**") - .target("server@esnext") - .instructions("> [server/index.tsx]") - .completed(proc => { - proc.require({ - // tslint:disable-next-line:no-shadowed-variable - close: ({ FuseBox }) => FuseBox.import(FuseBox.mainFile).shutdown() - }); - }); -}); -Sparky.task("dev", ["&clean", "&config", "&client", "&server"], () => { - fuse.run(); -}); +import { CSSPlugin, FuseBox, FuseBoxOptions, Sparky } from 'fuse-box'; +import path = require('path'); +import TsTransformClasscat from 'ts-transform-classcat'; +import TsTransformInferno from 'ts-transform-inferno'; +/** + * Some of FuseBoxOptions overrides by ts config (module, target, etc) + * https://fuse-box.org/page/working-with-targets + */ +let fuse: FuseBox; +const fuseOptions: FuseBoxOptions = { + homeDir: './src', + output: 'dist/$name.js', + sourceMaps: { inline: false, vendor: false }, + /** + * Custom TypeScript Transformers (compile Inferno tsx to ts) + */ + transformers: { + before: [TsTransformClasscat(), TsTransformInferno()], + }, +}; +const fuseClientOptions: FuseBoxOptions = { + ...fuseOptions, + plugins: [ + /** + * https://fuse-box.org/page/css-resource-plugin + * Compile Sass {SassPlugin()} + * Make .css files modules-like (allow import them like modules) {CSSModules} + * Make .css files modules like and allow import it from node_modules too {CSSResourcePlugin} + * Use them all and bundle with {CSSPlugin} + * */ + CSSPlugin(), + ], +}; +const fuseServerOptions: FuseBoxOptions = { + ...fuseOptions, +}; + +Sparky.task('clean', () => { + /**Clean distribute (dist) folder */ + Sparky.src('dist/').clean('dist/'); +}); +Sparky.task('config', () => { + fuse = FuseBox.init(fuseOptions); + fuse.dev(); +}); +Sparky.task('test', ['&clean', '&config'], () => { + fuse.bundle('client/bundle').test('[**/**.test.tsx]', null); +}); +Sparky.task('client', () => { + fuse.opts = fuseClientOptions; + fuse + .bundle('client/bundle') + .target('browser@esnext') + .watch('client/**') + .hmr() + .instructions('> client/index.tsx'); +}); +Sparky.task('copy-assets', () => + Sparky.src('**/**.*', { base: 'src/assets' }).dest('dist/assets') +); +Sparky.task('server', () => { + /**Workaround. Should be fixed */ + fuse.opts = fuseServerOptions; + fuse + .bundle('server/bundle') + .watch('**') + .target('server@esnext') + .instructions('> [server/index.tsx]') + .completed(proc => { + proc.require({ + // tslint:disable-next-line:no-shadowed-variable + close: ({ FuseBox }) => FuseBox.import(FuseBox.mainFile).shutdown(), + }); + }); +}); +Sparky.task( + 'dev', + ['&clean', '&config', '&client', '&server', '©-assets'], + () => { + fuse.run(); + } +); diff --git a/generate_translations.js b/generate_translations.js new file mode 100644 index 00000000..e792e6d2 --- /dev/null +++ b/generate_translations.js @@ -0,0 +1,27 @@ +fs = require('fs'); + +let translationDir = 'translations/'; +let outDir = 'src/shared/translations/'; +fs.mkdirSync(outDir, { recursive: true }); +fs.readdir(translationDir, (err, files) => { + files.forEach(filename => { + const lang = filename.split('.')[0]; + try { + const json = JSON.parse( + fs.readFileSync(translationDir + filename, 'utf8') + ); + var data = `export const ${lang} = {\n translation: {`; + for (var key in json) { + if (key in json) { + const value = json[key].replace(/"/g, '\\"'); + data = `${data}\n ${key}: "${value}",`; + } + } + data += '\n },\n};'; + const target = outDir + lang + '.ts'; + fs.writeFileSync(target, data); + } catch (err) { + console.error(err); + } + }); +}); diff --git a/package.json b/package.json index a619e62e..778c68e9 100644 --- a/package.json +++ b/package.json @@ -4,20 +4,48 @@ "author": "Dessalines ", "license": "AGPL-3.0", "scripts": { - "dev": "set NODE_ENV=development && node -r ts-node/register --inspect fuse.ts dev", "lint": "tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx src", + "prebuild": "node generate_translations.js", + "prestart": "node generate_translations.js", + "start": "set NODE_ENV=development && node -r ts-node/register --inspect fuse.ts dev", "test": "node -r ts-node/register --inspect fuse.ts test" }, "repository": "https://github.com/LemmyNet/lemmy-isomorphic-ui", "dependencies": { + "@types/autosize": "^3.0.6", + "@types/node-fetch": "^2.5.7", + "autosize": "^4.0.2", + "choices.js": "^9.0.1", "cookie-parser": "^1.4.3", + "emoji-short-name": "^1.0.0", "express": "~4.17.1", + "i18next": "^19.4.1", "inferno": "^7.4.3", "inferno-create-element": "^7.4.3", + "inferno-helmet": "^5.2.1", "inferno-hydrate": "^7.4.3", + "inferno-i18next": "github:nimbusec-oss/inferno-i18next#semver:^7.4.2", "inferno-router": "^7.4.3", "inferno-server": "^7.4.3", - "serialize-javascript": "^4.0.0" + "isomorphic-cookie": "^1.2.4", + "isomorphic-ws": "^4.0.1", + "js-cookie": "^2.2.0", + "jwt-decode": "^2.2.0", + "markdown-it": "^11.0.0", + "markdown-it-container": "^3.0.0", + "markdown-it-emoji": "^1.4.0", + "markdown-it-sub": "^1.0.0", + "markdown-it-sup": "^1.0.0", + "moment": "^2.24.0", + "node-fetch": "^2.6.0", + "reconnecting-websocket": "^4.4.0", + "rxjs": "^6.5.5", + "serialize-javascript": "^4.0.0", + "terser": "^4.6.11", + "tippy.js": "^6.1.1", + "toastify-js": "^1.7.0", + "tributejs": "^5.1.3", + "ws": "^7.3.1" }, "devDependencies": { "@types/cookie-parser": "^1.4.1", @@ -26,6 +54,7 @@ "@types/jest": "^26.0.10", "@types/node": "^14.6.0", "@types/serialize-javascript": "^4.0.0", + "classcat": "^4.1.0", "enzyme": "^3.3.0", "enzyme-adapter-inferno": "^1.3.0", "eslint": "^7.5.0", @@ -38,15 +67,19 @@ "jest": "^26.4.2", "jsdom": "16.4.0", "jsdom-global": "3.0.2", + "lemmy-js-client": "^1.0.8", "lint-staged": "^10.1.3", "prettier": "^2.0.4", "sortpack": "^2.1.4", "ts-node": "^9.0.0", "ts-transform-classcat": "^1.0.0", "ts-transform-inferno": "^4.0.3", - "tslint-react-recommended": "^1.0.15", "typescript": "^4.0.2" }, + "engines": { + "node": ">=8.9.0" + }, + "engineStrict": true, "husky": { "hooks": { "pre-commit": "lint-staged" diff --git a/src/client/components/About/About.css b/src/client/components/About/About.css deleted file mode 100644 index c59a6bbe..00000000 --- a/src/client/components/About/About.css +++ /dev/null @@ -1,10 +0,0 @@ -.text { - color: brown; - font-size: 25pt; -} -.count { - color: blue; -} -.button { - color: red; -} diff --git a/src/client/components/About/About.test.tsx b/src/client/components/About/About.test.tsx deleted file mode 100644 index 8a237295..00000000 --- a/src/client/components/About/About.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import 'jsdom-global/register'; -import { configure, mount, render, shallow } from 'enzyme'; -import InfernoEnzymeAdapter = require('enzyme-adapter-inferno'); -import { should } from 'fuse-test-runner'; -import { Component } from 'inferno'; -import { renderToSnapshot } from 'inferno-test-utils'; -import About from './About'; -configure({ adapter: new InfernoEnzymeAdapter() }); - -export class AboutTest { - public 'Should be okay'() { - const wrapper = mount(); - wrapper.find('.button').simulate('click'); - const countText = wrapper.find('.count').text(); - should(countText).beString().equal('1'); - } -} diff --git a/src/client/components/About/About.tsx b/src/client/components/About/About.tsx deleted file mode 100644 index 152c628f..00000000 --- a/src/client/components/About/About.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Component } from 'inferno'; -import './About.css'; -interface IState { - clickCount: number; -} -interface IProps {} -export default class About extends Component { - constructor(props) { - super(props); - this.state = { - clickCount: 0, - }; - this.increment = this.increment.bind(this); - } - protected increment() { - this.setState({ - clickCount: this.state.clickCount + 1, - }); - } - public render() { - return ( -
- Simple Inferno SSR template -

Hello, world!

- -

{this.state.clickCount}

-
- ); - } -} diff --git a/src/client/components/App/App.tsx b/src/client/components/App/App.tsx deleted file mode 100644 index 67836792..00000000 --- a/src/client/components/App/App.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Component, render } from 'inferno'; -import { Link, Route, StaticRouter, Switch } from 'inferno-router'; -import About from '../About/About'; -import Home from '../Home/Home'; -interface IState {} -interface IProps { - name: string; -} -export default class App extends Component { - constructor(props) { - super(props); - } - public render() { - return ( -
-
-

{this.props.name}

-
- -

Home

- -
-
- -

About

- -
-
-
- - - - -
-
- ); - } -} diff --git a/src/client/components/Home/Home.tsx b/src/client/components/Home/Home.tsx deleted file mode 100644 index 8d1febb5..00000000 --- a/src/client/components/Home/Home.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Component } from 'inferno'; -interface IState {} -interface IProps {} -export default class Home extends Component { - constructor(props) { - super(props); - } - protected click() { - /** - * Try to debug next line - */ - console.log('hi'); - } - public render() { - return ( -
- Home page - -
- ); - } -} diff --git a/src/client/index.tsx b/src/client/index.tsx index 2dde2a77..c2e404d3 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,8 +1,8 @@ import { Component } from 'inferno'; import { hydrate } from 'inferno-hydrate'; import { BrowserRouter } from 'inferno-router'; -import App from './components/App/App'; -import { initDevTools } from 'inferno-devtools'; +import { App } from '../shared/components/app'; +/* import { initDevTools } from 'inferno-devtools'; */ declare global { interface Window { @@ -14,8 +14,8 @@ declare global { const wrapper = ( - + ); -initDevTools(); +/* initDevTools(); */ hydrate(wrapper, document.getElementById('root')); diff --git a/src/server/index.tsx b/src/server/index.tsx index f100c979..fe50e00e 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,28 +1,35 @@ import cookieParser = require('cookie-parser'); -import * as serialize from 'serialize-javascript'; -import * as express from 'express'; +import serialize from 'serialize-javascript'; +import express from 'express'; import { StaticRouter } from 'inferno-router'; import { renderToString } from 'inferno-server'; +import { matchPath } from 'inferno-router'; import path = require('path'); -import App from '../client/components/App/App'; +import { App } from '../shared/components/app'; +import { routes } from '../shared/routes'; +import IsomorphicCookie from 'isomorphic-cookie'; const server = express(); const port = 1234; server.use(express.json()); server.use(express.urlencoded({ extended: false })); +server.use('/assets', express.static(path.resolve('./dist/assets'))); server.use('/static', express.static(path.resolve('./dist/client'))); server.use(cookieParser()); server.get('/*', (req, res) => { + const activeRoute = routes.find(route => matchPath(req.url, route)) || {}; + console.log(activeRoute); const context = {} as any; const isoData = { name: 'fishing sux', }; + let auth: string = IsomorphicCookie.load('jwt', req); const wrapper = ( - + ); if (context.url) { @@ -30,17 +37,38 @@ server.get('/*', (req, res) => { } res.send(` - - - - My Universal App - - - -
${renderToString(wrapper)}
- - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
${renderToString(wrapper)}
+ + + `); }); let Server = server.listen(port, () => { diff --git a/src/shared/components/admin-settings.tsx b/src/shared/components/admin-settings.tsx new file mode 100644 index 00000000..a3bfdd81 --- /dev/null +++ b/src/shared/components/admin-settings.tsx @@ -0,0 +1,260 @@ +import { Component, linkEvent } from 'inferno'; +import { Helmet } from 'inferno-helmet'; +import { Subscription } from 'rxjs'; +import { retryWhen, delay, take } from 'rxjs/operators'; +import { + UserOperation, + SiteResponse, + GetSiteResponse, + SiteConfigForm, + GetSiteConfigResponse, + WebSocketJsonResponse, +} from 'lemmy-js-client'; +import { WebSocketService } from '../services'; +import { wsJsonToRes, capitalizeFirstLetter, toast, randomStr } from '../utils'; +import autosize from 'autosize'; +import { SiteForm } from './site-form'; +import { UserListing } from './user-listing'; +import { i18n } from '../i18next'; + +interface AdminSettingsState { + siteRes: GetSiteResponse; + siteConfigRes: GetSiteConfigResponse; + siteConfigForm: SiteConfigForm; + loading: boolean; + siteConfigLoading: boolean; +} + +export class AdminSettings extends Component { + private siteConfigTextAreaId = `site-config-${randomStr()}`; + private subscription: Subscription; + private emptyState: AdminSettingsState = { + siteRes: { + site: { + id: null, + name: null, + creator_id: null, + creator_name: null, + published: null, + number_of_users: null, + number_of_posts: null, + number_of_comments: null, + number_of_communities: null, + enable_downvotes: null, + open_registration: null, + enable_nsfw: null, + }, + admins: [], + banned: [], + online: null, + version: null, + federated_instances: null, + }, + siteConfigForm: { + config_hjson: null, + auth: null, + }, + siteConfigRes: { + config_hjson: null, + }, + loading: true, + siteConfigLoading: null, + }; + + constructor(props: any, context: any) { + super(props, context); + + this.state = this.emptyState; + + this.subscription = WebSocketService.Instance.subject + .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10)))) + .subscribe( + msg => this.parseMessage(msg), + err => console.error(err), + () => console.log('complete') + ); + + WebSocketService.Instance.getSite(); + WebSocketService.Instance.getSiteConfig(); + } + + componentWillUnmount() { + this.subscription.unsubscribe(); + } + + get documentTitle(): string { + if (this.state.siteRes.site.name) { + return `${i18n.t('admin_settings')} - ${this.state.siteRes.site.name}`; + } else { + return 'Lemmy'; + } + } + + render() { + return ( +
+ + {this.state.loading ? ( +
+ + + +
+ ) : ( +
+
+ {this.state.siteRes.site.id && ( + + )} + {this.admins()} + {this.bannedUsers()} +
+
{this.adminSettings()}
+
+ )} +
+ ); + } + + admins() { + return ( + <> +
{capitalizeFirstLetter(i18n.t('admins'))}
+
    + {this.state.siteRes.admins.map(admin => ( +
  • + +
  • + ))} +
+ + ); + } + + bannedUsers() { + return ( + <> +
{i18n.t('banned_users')}
+
    + {this.state.siteRes.banned.map(banned => ( +
  • + +
  • + ))} +
+ + ); + } + + adminSettings() { + return ( +
+
{i18n.t('admin_settings')}
+
+
+ +
+