From 2eee93602687180976ad68de3d5f7a2cc4307cb3 Mon Sep 17 00:00:00 2001 From: Dessalines Date: Sun, 6 Sep 2020 11:15:25 -0500 Subject: [PATCH] Partly functioning fuse-box, but moving te webpack now. --- .eslintignore | 3 + .gitignore | 2 + fuse.ts | 158 +- generate_translations.js | 27 + package.json | 39 +- src/client/components/About/About.css | 10 - src/client/components/About/About.test.tsx | 17 - src/client/components/About/About.tsx | 32 - src/client/components/App/App.tsx | 38 - src/client/components/Home/Home.tsx | 22 - src/client/index.tsx | 8 +- src/server/index.tsx | 58 +- src/shared/components/admin-settings.tsx | 260 ++ src/shared/components/app.tsx | 42 + src/shared/components/banner-icon-header.tsx | 30 + src/shared/components/cake-day.tsx | 25 + src/shared/components/comment-form.tsx | 154 + src/shared/components/comment-node.tsx | 1208 +++++++ src/shared/components/comment-nodes.tsx | 74 + src/shared/components/communities.tsx | 258 ++ src/shared/components/community-form.tsx | 364 +++ src/shared/components/community-link.tsx | 60 + src/shared/components/community.tsx | 480 +++ src/shared/components/create-community.tsx | 105 + src/shared/components/create-post.tsx | 132 + .../components/create-private-message.tsx | 109 + src/shared/components/data-type-select.tsx | 71 + src/shared/components/footer.tsx | 89 + src/shared/components/iframely-card.tsx | 105 + src/shared/components/image-upload-form.tsx | 114 + src/shared/components/inbox.tsx | 607 ++++ src/shared/components/instances.tsx | 101 + src/shared/components/listing-type-select.tsx | 77 + src/shared/components/login.tsx | 485 +++ src/shared/components/main.tsx | 803 +++++ src/shared/components/markdown-textarea.tsx | 544 ++++ src/shared/components/modlog.tsx | 454 +++ src/shared/components/moment-time.tsx | 55 + src/shared/components/navbar.tsx | 556 ++++ src/shared/components/password_change.tsx | 162 + src/shared/components/post-form.tsx | 623 ++++ src/shared/components/post-listing.tsx | 1458 +++++++++ src/shared/components/post-listings.tsx | 115 + src/shared/components/post.tsx | 561 ++++ .../components/private-message-form.tsx | 288 ++ src/shared/components/private-message.tsx | 292 ++ src/shared/components/search.tsx | 536 ++++ src/shared/components/setup.tsx | 211 ++ src/shared/components/sidebar.tsx | 477 +++ src/shared/components/site-form.tsx | 300 ++ src/shared/components/sort-select.tsx | 76 + src/shared/components/sponsors.tsx | 211 ++ src/shared/components/symbols.tsx | 214 ++ src/shared/components/user-details.tsx | 315 ++ src/shared/components/user-listing.tsx | 75 + src/shared/components/user.tsx | 1109 +++++++ src/shared/env.ts | 15 + src/shared/i18next.ts | 79 + src/shared/interfaces.ts | 28 + src/shared/routes.ts | 77 + src/shared/services/UserService.ts | 59 + src/shared/services/WebSocketService.ts | 409 +++ src/shared/services/index.ts | 2 + src/shared/utils.ts | 1111 +++++++ tsconfig.json | 22 +- yarn.lock | 2804 ++++++++++++++++- 66 files changed, 19040 insertions(+), 365 deletions(-) create mode 100644 .eslintignore create mode 100644 generate_translations.js delete mode 100644 src/client/components/About/About.css delete mode 100644 src/client/components/About/About.test.tsx delete mode 100644 src/client/components/About/About.tsx delete mode 100644 src/client/components/App/App.tsx delete mode 100644 src/client/components/Home/Home.tsx create mode 100644 src/shared/components/admin-settings.tsx create mode 100644 src/shared/components/app.tsx create mode 100644 src/shared/components/banner-icon-header.tsx create mode 100644 src/shared/components/cake-day.tsx create mode 100644 src/shared/components/comment-form.tsx create mode 100644 src/shared/components/comment-node.tsx create mode 100644 src/shared/components/comment-nodes.tsx create mode 100644 src/shared/components/communities.tsx create mode 100644 src/shared/components/community-form.tsx create mode 100644 src/shared/components/community-link.tsx create mode 100644 src/shared/components/community.tsx create mode 100644 src/shared/components/create-community.tsx create mode 100644 src/shared/components/create-post.tsx create mode 100644 src/shared/components/create-private-message.tsx create mode 100644 src/shared/components/data-type-select.tsx create mode 100644 src/shared/components/footer.tsx create mode 100644 src/shared/components/iframely-card.tsx create mode 100644 src/shared/components/image-upload-form.tsx create mode 100644 src/shared/components/inbox.tsx create mode 100644 src/shared/components/instances.tsx create mode 100644 src/shared/components/listing-type-select.tsx create mode 100644 src/shared/components/login.tsx create mode 100644 src/shared/components/main.tsx create mode 100644 src/shared/components/markdown-textarea.tsx create mode 100644 src/shared/components/modlog.tsx create mode 100644 src/shared/components/moment-time.tsx create mode 100644 src/shared/components/navbar.tsx create mode 100644 src/shared/components/password_change.tsx create mode 100644 src/shared/components/post-form.tsx create mode 100644 src/shared/components/post-listing.tsx create mode 100644 src/shared/components/post-listings.tsx create mode 100644 src/shared/components/post.tsx create mode 100644 src/shared/components/private-message-form.tsx create mode 100644 src/shared/components/private-message.tsx create mode 100644 src/shared/components/search.tsx create mode 100644 src/shared/components/setup.tsx create mode 100644 src/shared/components/sidebar.tsx create mode 100644 src/shared/components/site-form.tsx create mode 100644 src/shared/components/sort-select.tsx create mode 100644 src/shared/components/sponsors.tsx create mode 100644 src/shared/components/symbols.tsx create mode 100644 src/shared/components/user-details.tsx create mode 100644 src/shared/components/user-listing.tsx create mode 100644 src/shared/components/user.tsx create mode 100644 src/shared/env.ts create mode 100644 src/shared/i18next.ts create mode 100644 src/shared/interfaces.ts create mode 100644 src/shared/routes.ts create mode 100644 src/shared/services/UserService.ts create mode 100644 src/shared/services/WebSocketService.ts create mode 100644 src/shared/services/index.ts create mode 100644 src/shared/utils.ts 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')}
+
+
+ +
+