Browse Source

Partly functioning fuse-box, but moving te webpack now.

bump_to_v0.9.0
Dessalines 1 year ago
parent
commit
2eee936026
  1. 3
      .eslintignore
  2. 2
      .gitignore
  3. 158
      fuse.ts
  4. 27
      generate_translations.js
  5. 39
      package.json
  6. 10
      src/client/components/About/About.css
  7. 17
      src/client/components/About/About.test.tsx
  8. 32
      src/client/components/About/About.tsx
  9. 38
      src/client/components/App/App.tsx
  10. 22
      src/client/components/Home/Home.tsx
  11. 8
      src/client/index.tsx
  12. 58
      src/server/index.tsx
  13. 260
      src/shared/components/admin-settings.tsx
  14. 42
      src/shared/components/app.tsx
  15. 30
      src/shared/components/banner-icon-header.tsx
  16. 25
      src/shared/components/cake-day.tsx
  17. 154
      src/shared/components/comment-form.tsx
  18. 1208
      src/shared/components/comment-node.tsx
  19. 74
      src/shared/components/comment-nodes.tsx
  20. 258
      src/shared/components/communities.tsx
  21. 364
      src/shared/components/community-form.tsx
  22. 60
      src/shared/components/community-link.tsx
  23. 480
      src/shared/components/community.tsx
  24. 105
      src/shared/components/create-community.tsx
  25. 132
      src/shared/components/create-post.tsx
  26. 109
      src/shared/components/create-private-message.tsx
  27. 71
      src/shared/components/data-type-select.tsx
  28. 89
      src/shared/components/footer.tsx
  29. 105
      src/shared/components/iframely-card.tsx
  30. 114
      src/shared/components/image-upload-form.tsx
  31. 607
      src/shared/components/inbox.tsx
  32. 101
      src/shared/components/instances.tsx
  33. 77
      src/shared/components/listing-type-select.tsx
  34. 485
      src/shared/components/login.tsx
  35. 803
      src/shared/components/main.tsx
  36. 544
      src/shared/components/markdown-textarea.tsx
  37. 454
      src/shared/components/modlog.tsx
  38. 55
      src/shared/components/moment-time.tsx
  39. 556
      src/shared/components/navbar.tsx
  40. 162
      src/shared/components/password_change.tsx
  41. 623
      src/shared/components/post-form.tsx
  42. 1458
      src/shared/components/post-listing.tsx
  43. 115
      src/shared/components/post-listings.tsx
  44. 561
      src/shared/components/post.tsx
  45. 288
      src/shared/components/private-message-form.tsx
  46. 292
      src/shared/components/private-message.tsx
  47. 536
      src/shared/components/search.tsx
  48. 211
      src/shared/components/setup.tsx
  49. 477
      src/shared/components/sidebar.tsx
  50. 300
      src/shared/components/site-form.tsx
  51. 76
      src/shared/components/sort-select.tsx
  52. 211
      src/shared/components/sponsors.tsx
  53. 214
      src/shared/components/symbols.tsx
  54. 315
      src/shared/components/user-details.tsx
  55. 75
      src/shared/components/user-listing.tsx
  56. 1109
      src/shared/components/user.tsx
  57. 15
      src/shared/env.ts
  58. 79
      src/shared/i18next.ts
  59. 28
      src/shared/interfaces.ts
  60. 77
      src/shared/routes.ts
  61. 59
      src/shared/services/UserService.ts
  62. 409
      src/shared/services/WebSocketService.ts
  63. 2
      src/shared/services/index.ts
  64. 1111
      src/shared/utils.ts
  65. 22
      tsconfig.json
  66. 2808
      yarn.lock

3
.eslintignore

@ -0,0 +1,3 @@
fuse.ts
generate_translations.js
src/api_tests

2
.gitignore

@ -25,3 +25,5 @@ test/data/result.json
package-lock.json
*.orig
src/shared/translations

158
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', '&copy-assets'],
() => {
fuse.run();
}
);

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

39
package.json

@ -4,20 +4,48 @@
"author": "Dessalines <tyhou13@gmx.com>",
"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"

10
src/client/components/About/About.css

@ -1,10 +0,0 @@
.text {
color: brown;
font-size: 25pt;
}
.count {
color: blue;
}
.button {
color: red;
}

17
src/client/components/About/About.test.tsx

@ -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(<About />);
wrapper.find('.button').simulate('click');
const countText = wrapper.find('.count').text();
should(countText).beString().equal('1');
}
}

32
src/client/components/About/About.tsx

@ -1,32 +0,0 @@
import { Component } from 'inferno';
import './About.css';
interface IState {
clickCount: number;
}
interface IProps {}
export default class About extends Component<IProps, IState> {
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 (
<div>
Simple Inferno SSR template
<p className="text">Hello, world!</p>
<button onClick={this.increment} className="button">
Increment
</button>
<p className="count">{this.state.clickCount}</p>
</div>
);
}
}

38
src/client/components/App/App.tsx

@ -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<IProps, IState> {
constructor(props) {
super(props);
}
public render() {
return (
<div>
<div>
<h3>{this.props.name}</h3>
<div>
<Link to="/Home">
<p>Home</p>
</Link>
</div>
<div>
<Link to="/About" className="link">
<p>About</p>
</Link>
</div>
</div>
<div>
<Switch>
<Route exact path="/Home" component={Home} />
<Route exact path="/About" component={About} />
</Switch>
</div>
</div>
);
}
}

22
src/client/components/Home/Home.tsx

@ -1,22 +0,0 @@
import { Component } from 'inferno';
interface IState {}
interface IProps {}
export default class Home extends Component<IProps, IState> {
constructor(props) {
super(props);
}
protected click() {
/**
* Try to debug next line
*/
console.log('hi');
}
public render() {
return (
<div>
Home page
<button onClick={this.click}>Click me</button>
</div>
);
}
}

8
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 = (
<BrowserRouter>
<App name={window.isoData.name} />
<App />
</BrowserRouter>
);
initDevTools();
/* initDevTools(); */
hydrate(wrapper, document.getElementById('root'));

58
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 = (
<StaticRouter location={req.url} context={context}>
<App name={isoData.name} />
<App />
</StaticRouter>
);
if (context.url) {
@ -30,17 +37,38 @@ server.get('/*', (req, res) => {
}
res.send(`
<!doctype html>
<html>
<head>
<title>My Universal App</title>
<script>window.isoData = ${serialize(isoData)}</script>
</head>
<body>
<div id='root'>${renderToString(wrapper)}</div>
<script src='./static/bundle.js'></script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<script>window.isoData = ${serialize(isoData)}</script>
<!-- Required meta tags -->
<meta name="Description" content="Lemmy">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Icons -->
<link rel="shortcut icon" type="image/svg+xml" href="/assets/favicon.svg" />
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png" />
<!-- Styles -->
<link rel="stylesheet" type="text/css" href="/assets/css/tribute.css" />
<link rel="stylesheet" type="text/css" href="/assets/css/toastify.css" />
<link rel="stylesheet" type="text/css" href="/assets/css/choices.min.css" />
<link rel="stylesheet" type="text/css" href="/assets/css/tippy.css" />
<link rel="stylesheet" type="text/css" href="/assets/css/themes/litely.min.css" id="default-light" media="(prefers-color-scheme: light)" />
<link rel="stylesheet" type="text/css" href="/assets/css/themes/darkly.min.css" id="default-dark" media="(prefers-color-scheme: no-preference), (prefers-color-scheme: dark)" />
<link rel="stylesheet" type="text/css" href="/assets/css/main.css" />
<!-- Scripts -->
<script async src="/assets/libs/sortable/sortable.min.js"></script>
</head>
<body>
<div id='root'>${renderToString(wrapper)}</div>
<script src='./static/bundle.js'></script>
</body>
</html>
`);
});
let Server = server.listen(port, () => {

260
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<any, AdminSettingsState> {
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 (
<div class="container">
<Helmet title={this.documentTitle} />
{this.state.loading ? (
<h5>
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
</h5>
) : (
<div class="row">
<div class="col-12 col-md-6">
{this.state.siteRes.site.id && (
<SiteForm site={this.state.siteRes.site} />
)}
{this.admins()}
{this.bannedUsers()}
</div>
<div class="col-12 col-md-6">{this.adminSettings()}</div>
</div>
)}
</div>
);
}
admins() {
return (
<>
<h5>{capitalizeFirstLetter(i18n.t('admins'))}</h5>
<ul class="list-unstyled">
{this.state.siteRes.admins.map(admin => (
<li class="list-inline-item">
<UserListing
user={{
name: admin.name,
preferred_username: admin.preferred_username,
avatar: admin.avatar,
id: admin.id,
local: admin.local,
actor_id: admin.actor_id,
}}
/>
</li>
))}
</ul>
</>
);
}
bannedUsers() {
return (
<>
<h5>{i18n.t('banned_users')}</h5>
<ul class="list-unstyled">
{this.state.siteRes.banned.map(banned => (
<li class="list-inline-item">
<UserListing
user={{
name: banned.name,
preferred_username: banned.preferred_username,
avatar: banned.avatar,
id: banned.id,
local: banned.local,
actor_id: banned.actor_id,
}}
/>
</li>
))}
</ul>
</>
);
}
adminSettings() {
return (
<div>
<h5>{i18n.t('admin_settings')}</h5>
<form onSubmit={linkEvent(this, this.handleSiteConfigSubmit)}>
<div class="form-group row">
<label
class="col-12 col-form-label"
htmlFor={this.siteConfigTextAreaId}
>
{i18n.t('site_config')}
</label>
<div class="col-12">
<textarea
id={this.siteConfigTextAreaId}
value={this.state.siteConfigForm.config_hjson}
onInput={linkEvent(this, this.handleSiteConfigHjsonChange)}
class="form-control text-monospace"
rows={3}
/>
</div>
</div>
<div class="form-group row">
<div class="col-12">
<button type="submit" class="btn btn-secondary mr-2">
{this.state.siteConfigLoading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
) : (
capitalizeFirstLetter(i18n.t('save'))
)}
</button>
</div>
</div>
</form>
</div>
);
}
handleSiteConfigSubmit(i: AdminSettings, event: any) {
event.preventDefault();
i.state.siteConfigLoading = true;
WebSocketService.Instance.saveSiteConfig(i.state.siteConfigForm);
i.setState(i.state);
}
handleSiteConfigHjsonChange(i: AdminSettings, event: any) {
i.state.siteConfigForm.config_hjson = event.target.value;
i.setState(i.state);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
this.context.router.history.push('/');
this.state.loading = false;
this.setState(this.state);
return;
} else if (msg.reconnect) {
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
// This means it hasn't been set up yet
if (!data.site) {
this.context.router.history.push('/setup');
}
this.state.siteRes = data;
this.setState(this.state);
} else if (res.op == UserOperation.EditSite) {
let data = res.data as SiteResponse;
this.state.siteRes.site = data.site;
this.setState(this.state);
toast(i18n.t('site_saved'));
} else if (res.op == UserOperation.GetSiteConfig) {
let data = res.data as GetSiteConfigResponse;
this.state.siteConfigRes = data;
this.state.loading = false;
this.state.siteConfigForm.config_hjson = this.state.siteConfigRes.config_hjson;
this.setState(this.state);
var textarea: any = document.getElementById(this.siteConfigTextAreaId);
autosize(textarea);
} else if (res.op == UserOperation.SaveSiteConfig) {
let data = res.data as GetSiteConfigResponse;
this.state.siteConfigRes = data;
this.state.siteConfigForm.config_hjson = this.state.siteConfigRes.config_hjson;
this.state.siteConfigLoading = false;
toast(i18n.t('site_saved'));
this.setState(this.state);
}
}
}

42
src/shared/components/app.tsx

@ -0,0 +1,42 @@
import { Component } from 'inferno';
import { Route, Switch } from 'inferno-router';
/* import { Provider } from 'inferno-i18next'; */
/* import { i18n } from './i18next'; */
import { routes } from '../../shared/routes';
import { Navbar } from '../../shared/components/navbar';
import { Footer } from '../../shared/components/footer';
import { Symbols } from '../../shared/components/symbols';
export class App extends Component<any, any> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
return (
<>
<h1>Hi there!</h1>
{/* <Provider i18next={i18n}> */}
<div>
<Navbar />
<div class="mt-4 p-0 fl-1">
<Switch>
{routes.map(({ path, exact, component: C, ...rest }) => (
<Route
key={path}
path={path}
exact={exact}
render={props => <C {...props} {...rest} />}
/>
))}
{/* <Route render={(props) => <NoMatch {...props} />} /> */}
</Switch>
<Symbols />
</div>
<Footer />
</div>
{/* </Provider> */}
</>
);
}
}

30
src/shared/components/banner-icon-header.tsx

@ -0,0 +1,30 @@
import { Component } from 'inferno';
interface BannerIconHeaderProps {
banner?: string;
icon?: string;
}
export class BannerIconHeader extends Component<BannerIconHeaderProps, any> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
return (
<div class="position-relative mb-2">
{this.props.banner && (
<img src={this.props.banner} class="banner img-fluid" />
)}
{this.props.icon && (
<img
src={this.props.icon}
className={`ml-2 mb-0 ${
this.props.banner ? 'avatar-pushup' : ''
} rounded-circle avatar-overlay`}
/>
)}
</div>
);
}
}

25
src/shared/components/cake-day.tsx

@ -0,0 +1,25 @@
import { Component } from 'inferno';
import { i18n } from '../i18next';
interface CakeDayProps {
creatorName: string;
}
export class CakeDay extends Component<CakeDayProps, any> {
render() {
return (
<div
className={`mx-2 d-inline-block unselectable pointer`}
data-tippy-content={this.cakeDayTippy()}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-cake"></use>
</svg>
</div>
);
}
cakeDayTippy(): string {
return i18n.t('cake_day_info', { creator_name: this.props.creatorName });
}
}

154
src/shared/components/comment-form.tsx

@ -0,0 +1,154 @@
import { Component } from 'inferno';
import { Link } from 'inferno-router';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
CommentNode as CommentNodeI,
CommentForm as CommentFormI,
WebSocketJsonResponse,
UserOperation,
CommentResponse,
} from 'lemmy-js-client';
import { capitalizeFirstLetter, wsJsonToRes } from '../utils';
import { WebSocketService, UserService } from '../services';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
import { MarkdownTextArea } from './markdown-textarea';
interface CommentFormProps {
postId?: number;
node?: CommentNodeI;
onReplyCancel?(): any;
edit?: boolean;
disabled?: boolean;
focus?: boolean;
}
interface CommentFormState {
commentForm: CommentFormI;
buttonTitle: string;
finished: boolean;
}
export class CommentForm extends Component<CommentFormProps, CommentFormState> {
private subscription: Subscription;
private emptyState: CommentFormState = {
commentForm: {
auth: null,
content: null,
post_id: this.props.node
? this.props.node.comment.post_id
: this.props.postId,
creator_id: UserService.Instance.user
? UserService.Instance.user.id
: null,
},
buttonTitle: !this.props.node
? capitalizeFirstLetter(i18n.t('post'))
: this.props.edit
? capitalizeFirstLetter(i18n.t('save'))
: capitalizeFirstLetter(i18n.t('reply')),
finished: false,
};
constructor(props: any, context: any) {
super(props, context);
this.handleCommentSubmit = this.handleCommentSubmit.bind(this);
this.handleReplyCancel = this.handleReplyCancel.bind(this);
this.state = this.emptyState;
if (this.props.node) {
if (this.props.edit) {
this.state.commentForm.edit_id = this.props.node.comment.id;
this.state.commentForm.parent_id = this.props.node.comment.parent_id;
this.state.commentForm.content = this.props.node.comment.content;
this.state.commentForm.creator_id = this.props.node.comment.creator_id;
} else {
// A reply gets a new parent id
this.state.commentForm.parent_id = this.props.node.comment.id;
}
}
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')
);
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
render() {
return (
<div class="mb-3">
{UserService.Instance.user ? (
<MarkdownTextArea
initialContent={this.state.commentForm.content}
buttonTitle={this.state.buttonTitle}
finished={this.state.finished}
replyType={!!this.props.node}
focus={this.props.focus}
disabled={this.props.disabled}
onSubmit={this.handleCommentSubmit}
onReplyCancel={this.handleReplyCancel}
/>
) : (
<div class="alert alert-light" role="alert">
<svg class="icon icon-inline mr-2">
<use xlinkHref="#icon-alert-triangle"></use>
</svg>
<T i18nKey="must_login" class="d-inline">
#
<Link class="alert-link" to="/login">
#
</Link>
</T>
</div>
)}
</div>
);
}
handleCommentSubmit(msg: { val: string; formId: string }) {
this.state.commentForm.content = msg.val;
this.state.commentForm.form_id = msg.formId;
if (this.props.edit) {
WebSocketService.Instance.editComment(this.state.commentForm);
} else {
WebSocketService.Instance.createComment(this.state.commentForm);
}
this.setState(this.state);
}
handleReplyCancel() {
this.props.onReplyCancel();
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
// Only do the showing and hiding if logged in
if (UserService.Instance.user) {
if (
res.op == UserOperation.CreateComment ||
res.op == UserOperation.EditComment
) {
let data = res.data as CommentResponse;
// This only finishes this form, if the randomly generated form_id matches the one received
if (this.state.commentForm.form_id == data.form_id) {
this.setState({ finished: true });
// Necessary because it broke tribute for some reaso
this.setState({ finished: false });
}
}
}
}
}

1208
src/shared/components/comment-node.tsx

File diff suppressed because it is too large

74
src/shared/components/comment-nodes.tsx

@ -0,0 +1,74 @@
import { Component } from 'inferno';
import { CommentSortType } from '../interfaces';
import {
CommentNode as CommentNodeI,
CommunityUser,
UserView,
SortType,
} from 'lemmy-js-client';
import { commentSort, commentSortSortType } from '../utils';
import { CommentNode } from './comment-node';
interface CommentNodesState {}
interface CommentNodesProps {
nodes: CommentNodeI[];
moderators?: CommunityUser[];
admins?: UserView[];
postCreatorId?: number;
noBorder?: boolean;
noIndent?: boolean;
viewOnly?: boolean;
locked?: boolean;
markable?: boolean;
showContext?: boolean;
showCommunity?: boolean;
sort?: CommentSortType;
sortType?: SortType;
enableDownvotes: boolean;
}
export class CommentNodes extends Component<
CommentNodesProps,
CommentNodesState
> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
return (
<div className="comments">
{this.sorter().map(node => (
<CommentNode
key={node.comment.id}
node={node}
noBorder={this.props.noBorder}
noIndent={this.props.noIndent}
viewOnly={this.props.viewOnly}
locked={this.props.locked}
moderators={this.props.moderators}
admins={this.props.admins}
postCreatorId={this.props.postCreatorId}
markable={this.props.markable}
showContext={this.props.showContext}
showCommunity={this.props.showCommunity}
sort={this.props.sort}
sortType={this.props.sortType}
enableDownvotes={this.props.enableDownvotes}
/>
))}
</div>
);
}
sorter(): CommentNodeI[] {
if (this.props.sort !== undefined) {
commentSort(this.props.nodes, this.props.sort);
} else if (this.props.sortType !== undefined) {
commentSortSortType(this.props.nodes, this.props.sortType);
}
return this.props.nodes;
}
}

258
src/shared/components/communities.tsx

@ -0,0 +1,258 @@
import { Component, linkEvent } from 'inferno';
import { Helmet } from 'inferno-helmet';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
UserOperation,
Community,
ListCommunitiesResponse,
CommunityResponse,
FollowCommunityForm,
ListCommunitiesForm,
SortType,
WebSocketJsonResponse,
GetSiteResponse,
Site,
} from 'lemmy-js-client';
import { WebSocketService } from '../services';
import { wsJsonToRes, toast, getPageFromProps } from '../utils';
import { CommunityLink } from './community-link';
import { i18n } from '../i18next';
declare const Sortable: any;
const communityLimit = 100;
interface CommunitiesState {
communities: Community[];
page: number;
loading: boolean;
site: Site;
}
interface CommunitiesProps {
page: number;
}
export class Communities extends Component<any, CommunitiesState> {
private subscription: Subscription;
private emptyState: CommunitiesState = {
communities: [],
loading: true,
page: getPageFromProps(this.props),
site: undefined,
};
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')
);
this.refetch();
WebSocketService.Instance.getSite();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
static getDerivedStateFromProps(props: any): CommunitiesProps {
return {
page: getPageFromProps(props),
};
}
componentDidUpdate(_: any, lastState: CommunitiesState) {
if (lastState.page !== this.state.page) {
this.setState({ loading: true });
this.refetch();
}
}
get documentTitle(): string {
if (this.state.site) {
return `${i18n.t('communities')} - ${this.state.site.name}`;
} else {
return 'Lemmy';
}
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
{this.state.loading ? (
<h5 class="">
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
</svg>
</h5>
) : (
<div>
<h5>{i18n.t('list_of_communities')}</h5>
<div class="table-responsive">
<table id="community_table" class="table table-sm table-hover">
<thead class="pointer">
<tr>
<th>{i18n.t('name')}</th>
<th>{i18n.t('category')}</th>
<th class="text-right">{i18n.t('subscribers')}</th>
<th class="text-right d-none d-lg-table-cell">
{i18n.t('posts')}
</th>
<th class="text-right d-none d-lg-table-cell">
{i18n.t('comments')}
</th>
<th></th>
</tr>
</thead>
<tbody>
{this.state.communities.map(community => (
<tr>
<td>
<CommunityLink community={community} />
</td>
<td>{community.category_name}</td>
<td class="text-right">
{community.number_of_subscribers}
</td>
<td class="text-right d-none d-lg-table-cell">
{community.number_of_posts}
</td>
<td class="text-right d-none d-lg-table-cell">
{community.number_of_comments}
</td>
<td class="text-right">
{community.subscribed ? (
<span
class="pointer btn-link"
onClick={linkEvent(
community.id,
this.handleUnsubscribe
)}
>
{i18n.t('unsubscribe')}
</span>
) : (
<span
class="pointer btn-link"
onClick={linkEvent(
community.id,
this.handleSubscribe
)}
>
{i18n.t('subscribe')}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{this.paginator()}
</div>
)}
</div>
);
}
paginator() {
return (
<div class="mt-2">
{this.state.page > 1 && (
<button
class="btn btn-secondary mr-1"
onClick={linkEvent(this, this.prevPage)}
>
{i18n.t('prev')}
</button>
)}
{this.state.communities.length > 0 && (
<button
class="btn btn-secondary"
onClick={linkEvent(this, this.nextPage)}
>
{i18n.t('next')}
</button>
)}
</div>
);
}
updateUrl(paramUpdates: CommunitiesProps) {
const page = paramUpdates.page || this.state.page;
this.props.history.push(`/communities/page/${page}`);
}
nextPage(i: Communities) {
i.updateUrl({ page: i.state.page + 1 });
}
prevPage(i: Communities) {
i.updateUrl({ page: i.state.page - 1 });
}
handleUnsubscribe(communityId: number) {
let form: FollowCommunityForm = {
community_id: communityId,
follow: false,
};
WebSocketService.Instance.followCommunity(form);
}
handleSubscribe(communityId: number) {
let form: FollowCommunityForm = {
community_id: communityId,
follow: true,
};
WebSocketService.Instance.followCommunity(form);
}
refetch() {
let listCommunitiesForm: ListCommunitiesForm = {
sort: SortType.TopAll,
limit: communityLimit,
page: this.state.page,
};
WebSocketService.Instance.listCommunities(listCommunitiesForm);
}
parseMessage(msg: WebSocketJsonResponse) {
console.log(msg);
let res = wsJsonToRes(msg);
if (msg.error) {
toast(i18n.t(msg.error), 'danger');
return;
} else if (res.op == UserOperation.ListCommunities) {
let data = res.data as ListCommunitiesResponse;
this.state.communities = data.communities;
this.state.communities.sort(
(a, b) => b.number_of_subscribers - a.number_of_subscribers
);
this.state.loading = false;
window.scrollTo(0, 0);
this.setState(this.state);
let table = document.querySelector('#community_table');
Sortable.initTable(table);
} else if (res.op == UserOperation.FollowCommunity) {
let data = res.data as CommunityResponse;
let found = this.state.communities.find(c => c.id == data.community.id);
found.subscribed = data.community.subscribed;
found.number_of_subscribers = data.community.number_of_subscribers;
this.setState(this.state);
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
this.state.site = data.site;
this.setState(this.state);
}
}
}

364
src/shared/components/community-form.tsx

@ -0,0 +1,364 @@
import { Component, linkEvent } from 'inferno';
import { Prompt } from 'inferno-router';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
import {
CommunityForm as CommunityFormI,
UserOperation,
Category,
ListCategoriesResponse,
CommunityResponse,
WebSocketJsonResponse,
Community,
} from 'lemmy-js-client';
import { WebSocketService } from '../services';
import { wsJsonToRes, capitalizeFirstLetter, toast, randomStr } from '../utils';
import { i18n } from '../i18next';
import { MarkdownTextArea } from './markdown-textarea';
import { ImageUploadForm } from './image-upload-form';
interface CommunityFormProps {
community?: Community; // If a community is given, that means this is an edit
onCancel?(): any;
onCreate?(community: Community): any;
onEdit?(community: Community): any;
enableNsfw: boolean;
}
interface CommunityFormState {
communityForm: CommunityFormI;
categories: Category[];
loading: boolean;
}
export class CommunityForm extends Component<
CommunityFormProps,
CommunityFormState
> {
private id = `community-form-${randomStr()}`;
private subscription: Subscription;
private emptyState: CommunityFormState = {
communityForm: {
name: null,
title: null,
category_id: null,
nsfw: false,
icon: null,
banner: null,
},
categories: [],
loading: false,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.handleCommunityDescriptionChange = this.handleCommunityDescriptionChange.bind(
this
);
this.handleIconUpload = this.handleIconUpload.bind(this);
this.handleIconRemove = this.handleIconRemove.bind(this);
this.handleBannerUpload = this.handleBannerUpload.bind(this);
this.handleBannerRemove = this.handleBannerRemove.bind(this);
if (this.props.community) {
this.state.communityForm = {
name: this.props.community.name,
title: this.props.community.title,
category_id: this.props.community.category_id,
description: this.props.community.description,
edit_id: this.props.community.id,
nsfw: this.props.community.nsfw,
icon: this.props.community.icon,
banner: this.props.community.banner,
auth: null,
};
}
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.listCategories();
}
componentDidUpdate() {
if (
!this.state.loading &&
(this.state.communityForm.name ||
this.state.communityForm.title ||
this.state.communityForm.description)
) {
window.onbeforeunload = () => true;
} else {
window.onbeforeunload = undefined;
}
}
componentWillUnmount() {
this.subscription.unsubscribe();
window.onbeforeunload = null;
}
render() {
return (
<>
<Prompt
when={
!this.state.loading &&
(this.state.communityForm.name ||
this.state.communityForm.title ||
this.state.communityForm.description)
}
message={i18n.t('block_leaving')}
/>
<form onSubmit={linkEvent(this, this.handleCreateCommunitySubmit)}>
{!this.props.community && (
<div class="form-group row">
<label class="col-12 col-form-label" htmlFor="community-name">
{i18n.t('name')}
<span
class="pointer unselectable ml-2 text-muted"