├── .babelrc
├── .github
└── workflows
│ └── nodejs.yml
├── .gitignore
├── .jshintrc
├── .prettierrc
├── .travis.yml
├── CHANGELOG.md
├── LICENSE
├── eslint.config.js
├── package-lock.json
├── package.json
├── public
├── images
│ ├── anzu.png
│ ├── anzu.svg
│ ├── default-post.jpg
│ ├── emojis.png
│ ├── emojis@2x.png
│ ├── faces.png
│ ├── faces_blue.png
│ ├── flags16.png
│ ├── logo.svg
│ └── noimage.jpg
└── sounds
│ ├── chat.mp3
│ └── notification.mp3
├── readme.md
├── src
├── acl.js
├── assets
│ ├── android-icon-144x144.png
│ ├── android-icon-192x192.png
│ ├── android-icon-36x36.png
│ ├── android-icon-48x48.png
│ ├── android-icon-72x72.png
│ ├── android-icon-96x96.png
│ ├── apple-icon-114x114.png
│ ├── apple-icon-120x120.png
│ ├── apple-icon-144x144.png
│ ├── apple-icon-152x152.png
│ ├── apple-icon-180x180.png
│ ├── apple-icon-57x57.png
│ ├── apple-icon-60x60.png
│ ├── apple-icon-72x72.png
│ ├── apple-icon-76x76.png
│ ├── apple-icon-precomposed.png
│ ├── apple-icon.png
│ ├── browserconfig.xml
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon-96x96.png
│ ├── favicon.ico
│ ├── images
│ │ ├── 404.jpg
│ │ ├── 404.png
│ │ ├── anzu.svg
│ │ └── facebook.svg
│ ├── index.html
│ ├── manifest.json
│ ├── ms-icon-144x144.png
│ ├── ms-icon-150x150.png
│ ├── ms-icon-310x310.png
│ ├── ms-icon-70x70.png
│ ├── notification.mp3
│ └── tribute.min.js
├── board
│ ├── components
│ │ ├── account.js
│ │ ├── actions.js
│ │ ├── author.js
│ │ ├── authorOptionsMenu.js
│ │ ├── banModal.js
│ │ ├── categoriesConfig.js
│ │ ├── chatChannelSettingsModal.js
│ │ ├── chatLogItem.js
│ │ ├── chatMessageInput.js
│ │ ├── chatMessageItem.js
│ │ ├── chatMessageList.js
│ │ ├── chatNavSidebar.js
│ │ ├── chatVideoPlayer.js
│ │ ├── comment.js
│ │ ├── configModal.js
│ │ ├── feed.js
│ │ ├── feedCategories.js
│ │ ├── flagModal.js
│ │ ├── forgotPassword.js
│ │ ├── generalConfig.js
│ │ ├── login.js
│ │ ├── modal.js
│ │ ├── navbar.js
│ │ ├── post.js
│ │ ├── profile.js
│ │ ├── publisher.js
│ │ ├── quickstart.js
│ │ ├── quickstartLink.js
│ │ ├── reader.js
│ │ ├── reply.js
│ │ ├── replyAdvice.js
│ │ ├── signup.js
│ │ └── usersModal.js
│ ├── containers
│ │ ├── chat.js
│ │ └── home.js
│ ├── errors.js
│ ├── fractals
│ │ ├── auth.js
│ │ ├── board.js
│ │ └── profile.js
│ └── utils.js
├── drivers
│ ├── ext
│ │ └── glue.js
│ └── tribute.js
├── hooks
│ ├── index.js
│ ├── useSessionState.js
│ ├── useStoredState.js
│ ├── useTitleNotification.js
│ └── useWindowVisibility.js
├── i18n.js
├── mount.js
├── requests.js
├── streams.js
├── themes
│ └── autumn
│ │ ├── _common.scss
│ │ ├── _components.scss
│ │ ├── _layout.scss
│ │ ├── _typicons.scss
│ │ ├── _variables.scss
│ │ ├── autumn.scss
│ │ ├── components
│ │ ├── _account.scss
│ │ ├── _chat.scss
│ │ ├── _feed.scss
│ │ ├── _modal.scss
│ │ ├── _navbar.scss
│ │ ├── _post.scss
│ │ ├── _profile.scss
│ │ ├── _publish.scss
│ │ └── _tributejs.scss
│ │ └── font
│ │ ├── typicons.eot
│ │ ├── typicons.svg
│ │ ├── typicons.ttf
│ │ ├── typicons.woff
│ │ └── typicons.woff2
└── utils.js
├── webpack.common.js
├── webpack.dev.js
└── webpack.prod.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env"],
3 | "plugins": ["@babel/plugin-proposal-class-properties"],
4 | "ignore": ["src/drivers/ext/glue.js"]
5 | }
--------------------------------------------------------------------------------
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | name: Node CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: ubuntu-latest
9 |
10 | strategy:
11 | matrix:
12 | node-version: [20.x, 22.x, 24.x]
13 |
14 | steps:
15 | - uses: actions/checkout@v4
16 | - name: Use Node.js ${{ matrix.node-version }}
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 | cache: 'npm'
21 | - name: npm install, build, and test
22 | run: |
23 | npm ci --legacy-peer-deps
24 | npm run build --if-present
25 | npm run eslint --if-present
26 | env:
27 | CI: true
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .AppleDouble
3 | .LSOverride
4 | # Thumbnails
5 | ._*
6 | public/js/config.js
7 | config.codekit
8 | config.codekit3
9 | node_modules
10 | /bower_components
11 | /public/css/app.css
12 | /public/css/app.css.map
13 | /public/css/store.css
14 | /public/js/main.js
15 | /public/css/store.css.map
16 | public/dist
17 | webpack-stats.json
--------------------------------------------------------------------------------
/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "esversion": 6
3 | }
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 4,
4 | "useTabs": false,
5 | "singleQuote": true,
6 | "trailingComma": "es5",
7 | "bracketSpacing": true,
8 | "arrowParens": "avoid",
9 | "semi": true,
10 | "endOfLine": "lf",
11 | "htmlWhitespaceSensitivity": "css",
12 | "jsxBracketSameLine": false,
13 | "proseWrap": "preserve",
14 | "requirePragma": false
15 | }
16 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "6"
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # [1.0.0-alpha.2](https://github.com/tryanzu/frontend/compare/v1.0.0-alpha.1...v1.0.0-alpha.2) (2020-09-22)
2 |
3 | # [1.0.0-alpha.1](https://github.com/tryanzu/frontend/compare/v0.1.0...v1.0.0-alpha.1) (2020-09-22)
4 |
5 |
6 | ### Bug Fixes
7 |
8 | * eslint issues ([d36ec0a](https://github.com/tryanzu/frontend/commit/d36ec0a648afa0bf07cabf79f4eb51fc56295b15))
9 | * feed item menu event propagation ([378d2b4](https://github.com/tryanzu/frontend/commit/378d2b44598988ba3dbd4d1ffaad9a8579d1bf3d))
10 | * **actions:** agregando las opciones de baneo en un array ([9ae3ce3](https://github.com/tryanzu/frontend/commit/9ae3ce3d7bf950e3ade600c33a432688e5ea1dba))
11 | * **actions:** cambiando los valores de reasons ([6ba5f96](https://github.com/tryanzu/frontend/commit/6ba5f96e58269b9c4311c0d11f1ccc09e0f08122))
12 | * **ci:** jshintrc version. ([74215e4](https://github.com/tryanzu/frontend/commit/74215e4bcea56ba1503df1f8547f6adf2e7ae92f))
13 | * **comment:** editing and deleting tools, only for the author ([#40](https://github.com/tryanzu/frontend/issues/40)) ([f74f82d](https://github.com/tryanzu/frontend/commit/f74f82d3929e2d7b6c717d4216b8c903672ba323))
14 | * **comments:** hide the empty comment box message ([#36](https://github.com/tryanzu/frontend/issues/36)) ([19190a7](https://github.com/tryanzu/frontend/commit/19190a739ed54ef3ec40cffb7cedfde8a8ad9074))
15 | * **profile:** show another user's profile avatar ([#24](https://github.com/tryanzu/frontend/issues/24)) ([f4a9f7a](https://github.com/tryanzu/frontend/commit/f4a9f7af8729daff48c902f7cdbc3b3ac2e91a28))
16 | * **readme:** updating screenshots and info ([#23](https://github.com/tryanzu/frontend/issues/23)) ([690d70b](https://github.com/tryanzu/frontend/commit/690d70bbe5363dacc1c80aabe07a79b74f1e061a))
17 | * categories state after sign in. [#6](https://github.com/tryanzu/frontend/issues/6) ([f1c95b1](https://github.com/tryanzu/frontend/commit/f1c95b10db780d8c5fe81ce28dc639690569d30b))
18 | * facebook buttons issues ([66f4fde](https://github.com/tryanzu/frontend/commit/66f4fde22be724678c5b3884ccd5435b7353e102))
19 | * github deps alerts ([4b5fa5a](https://github.com/tryanzu/frontend/commit/4b5fa5ad857f0fa0f8cc10c65d9d1aeb7a97d74a))
20 | * mobile NeedAccountValidation [#9](https://github.com/tryanzu/frontend/issues/9) ([f167788](https://github.com/tryanzu/frontend/commit/f167788802ae51e6bae8f4173bb6487fd297b5ef))
21 | * querystring dependency issue. ([f4cd45f](https://github.com/tryanzu/frontend/commit/f4cd45f416abe73d991341c5fcb3bf57faf431e9))
22 | * updated deps && linter issues. ([c307c6b](https://github.com/tryanzu/frontend/commit/c307c6bc0899dc134204d4448460f46e239c5272))
23 |
24 |
25 | ### Features
26 |
27 | * author options menu ([f798109](https://github.com/tryanzu/frontend/commit/f798109d825d53ecd24654e199498592a0825a3a))
28 | * **chat:** private channels ([c36ed35](https://github.com/tryanzu/frontend/commit/c36ed351987d83b5d3fda78a3999f344f801ddcf))
29 | * **chat:** turn off/on the sound of notifications ([#25](https://github.com/tryanzu/frontend/issues/25)) ([786468b](https://github.com/tryanzu/frontend/commit/786468b10c5a0ffe02f21c42c0d1c8649d7faf56))
30 | * **chat:** twitch video ([#44](https://github.com/tryanzu/frontend/issues/44)) ([4d8f19b](https://github.com/tryanzu/frontend/commit/4d8f19b09760d98776ba123e664fccac0c449d02))
31 | * **config:** updatable nav links ([#29](https://github.com/tryanzu/frontend/issues/29)) ([2e6fbb1](https://github.com/tryanzu/frontend/commit/2e6fbb180e721fd461922c8f41b0d5db039ab365))
32 | * **feed:** custom scrollbar & increased feed default limit ([#39](https://github.com/tryanzu/frontend/issues/39)) ([31c1a29](https://github.com/tryanzu/frontend/commit/31c1a2922e15d80c443c688cd9b5bf47ef4c5156))
33 | * Ban a user modal ([baa8590](https://github.com/tryanzu/frontend/commit/baa8590dc7cc266d8c20b2d0fad284b0ead40b0a))
34 | * ajustando algunos margenes ([935d723](https://github.com/tryanzu/frontend/commit/935d72379ff797ff2394b74276276547cc38829a))
35 | * ban a user side effects. ([96f8b3c](https://github.com/tryanzu/frontend/commit/96f8b3c206cad0a586f027a8b5c91a60ab3749f6))
36 | * banning & flag reasons from remote ([ede2e6d](https://github.com/tryanzu/frontend/commit/ede2e6d0d9cbf08595e692f2191f2bcafbe8d695))
37 | * webpack bundler ([#38](https://github.com/tryanzu/frontend/issues/38)) ([989d66d](https://github.com/tryanzu/frontend/commit/989d66daf1debed6f25f6fa7c68a15656ed7e3ad))
38 | * **_modals.scss:** padding del modal flagpost ([41df956](https://github.com/tryanzu/frontend/commit/41df956ab9408dd3162fbfdf05c6a46e3b5753b5))
39 | * **drafts:** simple drafts for post and comments ([#33](https://github.com/tryanzu/frontend/issues/33)) ([82192de](https://github.com/tryanzu/frontend/commit/82192decccfa7c5cbacd5f6c80b36aa91ad3069f))
40 | * **publisher:** simpler publish flow. ([99af44d](https://github.com/tryanzu/frontend/commit/99af44d55ad1076769317fe75fc65beb9a22dfea))
41 | * **quickstart:** updatable ([#22](https://github.com/tryanzu/frontend/issues/22)) ([5b898a7](https://github.com/tryanzu/frontend/commit/5b898a7719546e7c2d2049243f64d0bac853d005))
42 | * **users:** last seen indicator ([#28](https://github.com/tryanzu/frontend/issues/28)) ([e3eda3e](https://github.com/tryanzu/frontend/commit/e3eda3ea31a228988dff82b06db3e0185e58269f))
43 | * flag post/comment ([cb5214f](https://github.com/tryanzu/frontend/commit/cb5214f8435af6c11414fc30938a9ad24fa582e1))
44 | * motivos en un select ([c77c8c6](https://github.com/tryanzu/frontend/commit/c77c8c6560d25fe4aff08c1e8a76ad902840569f))
45 | * public chat ([#17](https://github.com/tryanzu/frontend/issues/17)) ([d25b748](https://github.com/tryanzu/frontend/commit/d25b7487af37ca35278bf124e8dbcf95778d5295)), closes [#2](https://github.com/tryanzu/frontend/issues/2)
46 | * wrapping text and adding strings to the translations map ([bba3da1](https://github.com/tryanzu/frontend/commit/bba3da1cbe7e04010ab310ec3a8c43db775fc584))
47 |
48 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | const js = require('@eslint/js');
2 | const prettier = require('eslint-plugin-prettier');
3 | const prettierConfig = require('eslint-config-prettier');
4 |
5 | module.exports = [
6 | {
7 | ignores: ['**/*.min.js', 'node_modules', 'src/drivers/ext/glue.js'],
8 | },
9 | js.configs.recommended,
10 | prettierConfig,
11 | {
12 | plugins: {
13 | prettier,
14 | },
15 | languageOptions: {
16 | parser: require('@babel/eslint-parser'),
17 | parserOptions: {
18 | ecmaVersion: 2020,
19 | sourceType: 'module',
20 | },
21 | globals: {
22 | window: true,
23 | Anzu: true,
24 | console: true,
25 | Tribute: true,
26 | Promise: true,
27 | document: true,
28 | CURRENT_VERSION: true,
29 | },
30 | },
31 | rules: {
32 | 'function-paren-newline': [0],
33 | 'no-console': [0],
34 | 'object-property-newline': [
35 | 1,
36 | { allowMultiplePropertiesPerLine: true },
37 | ],
38 | 'prettier/prettier': [
39 | 'error',
40 | {
41 | printWidth: 80,
42 | singleQuote: true,
43 | trailingComma: 'es5',
44 | tabWidth: 4,
45 | useTabs: false,
46 | bracketSpacing: true,
47 | arrowParens: 'avoid',
48 | semi: true,
49 | endOfLine: 'lf',
50 | htmlWhitespaceSensitivity: 'css',
51 | jsxBracketSameLine: false,
52 | proseWrap: 'preserve',
53 | requirePragma: false,
54 | },
55 | ],
56 | },
57 | },
58 | ];
59 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "anzu.frontend",
3 | "version": "1.0.0-alpha.2",
4 | "description": "The next generation community-engine. frontend",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/tryanzu/frontend.git"
8 | },
9 | "author": "fernandez14",
10 | "license": "ISC",
11 | "bugs": {
12 | "url": "https://github.com/tryanzu/frontend/issues"
13 | },
14 | "homepage": "https://github.com/tryanzu/frontend#readme",
15 | "scripts": {
16 | "start": "webpack --watch --config webpack.dev.js",
17 | "build": "webpack --config webpack.prod.js",
18 | "release": "release-it --preRelease=alpha --no-npm.publish",
19 | "eslint": "node_modules/eslint/bin/eslint.js src/. --fix"
20 | },
21 | "dependencies": {
22 | "@types/history": "^4.7.5",
23 | "autosize": "^4.0.2",
24 | "babel-polyfill": "^6.26.0",
25 | "callbag-basics": "^3.2.0",
26 | "callbag-basics-esmodules": "^4.0.0",
27 | "callbag-debounce": "^2.1.2",
28 | "callbag-observe": "^1.0.0",
29 | "callbag-subscribe": "^1.5.1",
30 | "classnames": "^2.2.6",
31 | "date-fns": "^1.30.1",
32 | "deep-equal": "^1.1.1",
33 | "emoji-dictionary": "^1.0.9",
34 | "extend": ">=3.0.2",
35 | "freactal": "^2.0.3",
36 | "functional-acl": "^0.6.0",
37 | "hyperscript-helpers": "^3.0.3",
38 | "install": "^0.10.2",
39 | "jed": "^1.1.1",
40 | "lodash": "^4.17.19",
41 | "lost": "^8.3.1",
42 | "number-format.js": "^1.1.11",
43 | "os": "^0.1.1",
44 | "process": "^0.11.10",
45 | "qs": "^6.9.3",
46 | "query-string": "^5.1.1",
47 | "react": "^16.13.1",
48 | "react-autosize-textarea": "^6.0.0",
49 | "react-debounce-input": "^3.2.2",
50 | "react-dom": "^16.13.1",
51 | "react-dropzone": "^8.0.4",
52 | "react-hyperscript": "^3.2.0",
53 | "react-markdown": "^5.0.3",
54 | "react-modal": "^3.11.2",
55 | "react-router-dom": "^4.3.1",
56 | "react-rte": "^0.16.1",
57 | "react-toastify": "^4.5.2",
58 | "react-twitch-embed-video": "^1.1.4",
59 | "react-youtube": "^7.9.0",
60 | "spectre.css": "^0.5.8",
61 | "switch-path": "^1.2.0",
62 | "tachyons": "^4.11.1",
63 | "timeago.js": "^3.0.2",
64 | "tributejs": "^4.1.3"
65 | },
66 | "devDependencies": {
67 | "@babel/core": "^7.12.10",
68 | "@babel/eslint-parser": "^7.28.0",
69 | "@babel/plugin-proposal-class-properties": "^7.12.1",
70 | "@babel/preset-env": "^7.12.11",
71 | "@release-it/conventional-changelog": "^2.0.0",
72 | "babel-eslint": "^10.1.0",
73 | "babel-loader": "^8.2.2",
74 | "babel-minify-webpack-plugin": "^0.3.1",
75 | "braces": ">=2.3.1",
76 | "clean-css": "^4.2.3",
77 | "clean-webpack-plugin": "^3.0.0",
78 | "closure-webpack-plugin": "^2.3.0",
79 | "css-loader": "^3.4.2",
80 | "eslint": "^9.31.0",
81 | "eslint-config-prettier": "^10.1.5",
82 | "eslint-plugin-prettier": "^5.5.1",
83 | "file-loader": "^4.3.0",
84 | "mini-css-extract-plugin": "^1.3.3",
85 | "optimize-plugin": "^1.0.0",
86 | "path-browserify": "^1.0.1",
87 | "prettier": "^3.6.2",
88 | "release-it": "^14.0.3",
89 | "sass": "^1.89.2",
90 | "sass-loader": "^12.6.0",
91 | "style-loader": "^1.1.3",
92 | "terser-webpack-plugin": "^4.2.3",
93 | "webpack": "^5.88.0",
94 | "webpack-cli": "^4.2.0",
95 | "webpack-merge": "^5.7.2",
96 | "webpack-plugin-modern-npm": "^0.1.0",
97 | "ws": "^7.2.3"
98 | },
99 | "release-it": {
100 | "git": {
101 | "commitMessage": "chore: release v${version}"
102 | },
103 | "github": {
104 | "release": true
105 | },
106 | "hooks": {
107 | "before:init": [
108 | "npm run eslint"
109 | ]
110 | },
111 | "plugins": {
112 | "@release-it/conventional-changelog": {
113 | "preset": "angular",
114 | "infile": "CHANGELOG.md"
115 | }
116 | }
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/public/images/anzu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/public/images/anzu.png
--------------------------------------------------------------------------------
/public/images/anzu.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/images/default-post.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/public/images/default-post.jpg
--------------------------------------------------------------------------------
/public/images/emojis.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/public/images/emojis.png
--------------------------------------------------------------------------------
/public/images/emojis@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/public/images/emojis@2x.png
--------------------------------------------------------------------------------
/public/images/faces.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/public/images/faces.png
--------------------------------------------------------------------------------
/public/images/faces_blue.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/public/images/faces_blue.png
--------------------------------------------------------------------------------
/public/images/flags16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/public/images/flags16.png
--------------------------------------------------------------------------------
/public/images/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
55 |
--------------------------------------------------------------------------------
/public/images/noimage.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/public/images/noimage.jpg
--------------------------------------------------------------------------------
/public/sounds/chat.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/public/sounds/chat.mp3
--------------------------------------------------------------------------------
/public/sounds/notification.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/public/sounds/notification.mp3
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Meet Anzu
2 |
3 | Anzu is our greatest endeavor to build the most rad, simple & reactive forum software out there since the Javascript revolution.
4 |
5 | Forum platforms to host communities are vast. Many would say it's a lifeless space with almost zero innovation, and attempting to create something new is pointless. We dissent, and if you found this repository you might also share with us the idea that there has to be an alternative to the old forum. Well, we think Anzu is that young and sexy software that could bring back to life the community-building movement.
6 |
7 | This repository contains the front-end repository.
8 |
9 | We're still working in the first alpha, so previous knowledge about the stack is required to set things up.
10 |
11 | ## Anzu's stack
12 | - Golang.
13 | - Redis (to be replaced)
14 | - BuntDB (embedded cache)
15 | - MongoDB (DB)
16 | - React JS (with a heavy use of hooks)
17 |
18 | ## Installation
19 |
20 | ### Download dependencies
21 | The first step is to download and install Go, official binary distributions are available at [https://golang.org/dl/](https://golang.org/dl/).
22 |
23 | Download and configure **MongoDB** and **Redis** (you'll need to create a root user in MongoDB). Alternatively you can use remote servers.
24 |
25 | ### Download the repositories
26 |
27 | Download the [core](http://github.com/tryanzu/anzu) in any path.
28 |
29 | Initialize the repo submodule, so the [frontend](http://github.com/tryanzu/frontend) is in `static/frontend`.
30 |
31 | ```
32 | git submodule update --init --recursive
33 | ```
34 |
35 | Install andn build the core dependencies with `go build -o anzu`. A binary named anzu will be created and it is the program we'll run to create an anzu forum.
36 |
37 | Now go to the frontend folder in `static/frontend`, install the frontend dependencies with `npm install` and finally compile the frontend with `npm run build`.
38 |
39 | ### Configure
40 | Copy the `.env.example` file into `.env` and edit it to meet your local environment configuration. Environment config can be setup either using OS env vars or .env file. This config is read at anzu boot time and it is core to be able to run the program.
41 |
42 | Copy the `config.toml.example` file into `config.toml` and edit it to meet your site configuration.
43 |
44 | ### Last steps
45 | Having mongodb & redis running and everything set up in .env we can now start the program.
46 |
47 | Execute `./anzu` and have fun.
48 |
49 | ## Commits
50 |
51 | We follow the [Conventional Commits](https://www.conventionalcommits.org) specification, which help us with automatic semantic versioning and CHANGELOG generation.
--------------------------------------------------------------------------------
/src/acl.js:
--------------------------------------------------------------------------------
1 | import { combineRules, allow, deny } from 'functional-acl';
2 |
3 | export const permissions = {
4 | administrator: {
5 | permissions: ['board-config', 'sensitive-data'],
6 | parents: ['super-moderator'],
7 | },
8 | 'category-moderator': {
9 | permissions: [],
10 | parents: ['child-moderator'],
11 | },
12 | 'child-moderator': {
13 | permissions: [
14 | 'block-category-post-comments',
15 | 'edit-category-comments',
16 | 'edit-category-posts',
17 | 'solve-category-posts',
18 | 'delete-category-posts',
19 | 'delete-category-comments',
20 | ],
21 | parents: ['spartan-girl'],
22 | },
23 | developer: {
24 | permissions: ['debug', 'dev-tools'],
25 | parents: ['administrator'],
26 | },
27 | editor: {
28 | permissions: [],
29 | parents: ['user'],
30 | },
31 | 'spartan-girl': {
32 | permissions: ['block-own-post-comments'],
33 | parents: ['user'],
34 | },
35 | 'super-moderator': {
36 | permissions: [
37 | 'block-board-post-comments',
38 | 'edit-board-comments',
39 | 'edit-board-posts',
40 | 'solve-board-posts',
41 | 'delete-board-comments',
42 | 'delete-board-posts',
43 | 'pin-board-posts',
44 | ],
45 | parents: ['category-moderator'],
46 | },
47 | user: {
48 | permissions: [
49 | 'publish',
50 | 'comment',
51 | 'edit-own-posts',
52 | 'solve-own-posts',
53 | 'delete-own-posts',
54 | 'edit-own-comments',
55 | 'delete-own-comments',
56 | ],
57 | parents: [],
58 | },
59 | };
60 |
61 | const admins = ({ user }) =>
62 | user &&
63 | user.roles.filter(
64 | ({ name }) => name == 'developer' || name == 'administrator'
65 | ).length > 0;
66 | const guests = ({ user }) => !user;
67 | export const reading = ({ operation }) => operation === 'read';
68 | export const writing = ({ operation }) => operation === 'write';
69 |
70 | export const adminTools = combineRules(deny(guests), allow(admins));
71 |
--------------------------------------------------------------------------------
/src/assets/android-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/android-icon-144x144.png
--------------------------------------------------------------------------------
/src/assets/android-icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/android-icon-192x192.png
--------------------------------------------------------------------------------
/src/assets/android-icon-36x36.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/android-icon-36x36.png
--------------------------------------------------------------------------------
/src/assets/android-icon-48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/android-icon-48x48.png
--------------------------------------------------------------------------------
/src/assets/android-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/android-icon-72x72.png
--------------------------------------------------------------------------------
/src/assets/android-icon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/android-icon-96x96.png
--------------------------------------------------------------------------------
/src/assets/apple-icon-114x114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/apple-icon-114x114.png
--------------------------------------------------------------------------------
/src/assets/apple-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/apple-icon-120x120.png
--------------------------------------------------------------------------------
/src/assets/apple-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/apple-icon-144x144.png
--------------------------------------------------------------------------------
/src/assets/apple-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/apple-icon-152x152.png
--------------------------------------------------------------------------------
/src/assets/apple-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/apple-icon-180x180.png
--------------------------------------------------------------------------------
/src/assets/apple-icon-57x57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/apple-icon-57x57.png
--------------------------------------------------------------------------------
/src/assets/apple-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/apple-icon-60x60.png
--------------------------------------------------------------------------------
/src/assets/apple-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/apple-icon-72x72.png
--------------------------------------------------------------------------------
/src/assets/apple-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/apple-icon-76x76.png
--------------------------------------------------------------------------------
/src/assets/apple-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/apple-icon-precomposed.png
--------------------------------------------------------------------------------
/src/assets/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/apple-icon.png
--------------------------------------------------------------------------------
/src/assets/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 | #ffffff
--------------------------------------------------------------------------------
/src/assets/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/favicon-16x16.png
--------------------------------------------------------------------------------
/src/assets/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/favicon-32x32.png
--------------------------------------------------------------------------------
/src/assets/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/favicon-96x96.png
--------------------------------------------------------------------------------
/src/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/favicon.ico
--------------------------------------------------------------------------------
/src/assets/images/404.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/images/404.jpg
--------------------------------------------------------------------------------
/src/assets/images/404.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/images/404.png
--------------------------------------------------------------------------------
/src/assets/images/anzu.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/images/facebook.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Anzu static frontend
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
42 |
43 |
44 |
45 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/src/assets/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "App",
3 | "icons": [
4 | {
5 | "src": "\/android-icon-36x36.png",
6 | "sizes": "36x36",
7 | "type": "image\/png",
8 | "density": "0.75"
9 | },
10 | {
11 | "src": "\/android-icon-48x48.png",
12 | "sizes": "48x48",
13 | "type": "image\/png",
14 | "density": "1.0"
15 | },
16 | {
17 | "src": "\/android-icon-72x72.png",
18 | "sizes": "72x72",
19 | "type": "image\/png",
20 | "density": "1.5"
21 | },
22 | {
23 | "src": "\/android-icon-96x96.png",
24 | "sizes": "96x96",
25 | "type": "image\/png",
26 | "density": "2.0"
27 | },
28 | {
29 | "src": "\/android-icon-144x144.png",
30 | "sizes": "144x144",
31 | "type": "image\/png",
32 | "density": "3.0"
33 | },
34 | {
35 | "src": "\/android-icon-192x192.png",
36 | "sizes": "192x192",
37 | "type": "image\/png",
38 | "density": "4.0"
39 | }
40 | ]
41 | }
--------------------------------------------------------------------------------
/src/assets/ms-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/ms-icon-144x144.png
--------------------------------------------------------------------------------
/src/assets/ms-icon-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/ms-icon-150x150.png
--------------------------------------------------------------------------------
/src/assets/ms-icon-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/ms-icon-310x310.png
--------------------------------------------------------------------------------
/src/assets/ms-icon-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/ms-icon-70x70.png
--------------------------------------------------------------------------------
/src/assets/notification.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/assets/notification.mp3
--------------------------------------------------------------------------------
/src/board/components/account.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import classNames from 'classnames';
3 | import Modal from 'react-modal';
4 | import helpers from 'hyperscript-helpers';
5 | import { t } from '../../i18n';
6 | import { Login } from './login';
7 | import { Signup } from './signup';
8 | import { Link } from 'react-router-dom';
9 | import { ForgotPassword } from './forgotPassword';
10 |
11 | const tags = helpers(h);
12 | const { div, img, ul, li, a, p } = tags;
13 |
14 | export function Account({ state, effects, ...props }) {
15 | const { auth } = state;
16 | const isOpen = props.alwaysOpen || auth.modal;
17 | return h(
18 | Modal,
19 | {
20 | isOpen,
21 | onRequestClose: () => effects.auth('modal', false),
22 | ariaHideApp: false,
23 | contentLabel: t`Tu cuenta en` + ' ' + state.site.name,
24 | className: 'account-modal',
25 | style: {
26 | overlay: {
27 | zIndex: 301,
28 | backgroundColor: 'rgba(0, 0, 0, 0.30)',
29 | },
30 | },
31 | },
32 | [
33 | div('.modal-container', { style: {} }, [
34 | div(
35 | '.modal-body',
36 | { style: { paddingTop: '0', maxHeight: '85vh' } },
37 | [
38 | div('.tc.pv3', { style: { margin: '0 -0.8rem' } }, [
39 | h(
40 | Link,
41 | { to: '/' },
42 | img('.w3', {
43 | src:
44 | state.site.logoUrl ||
45 | '/images/anzu.svg',
46 | alt: t`Únete a la conversación`,
47 | })
48 | ),
49 | ]),
50 | ul(
51 | '.tab.tab-block',
52 | { style: { margin: '0 -0.8rem 1.2rem' } },
53 | [
54 | li(
55 | '.tab-item.pointer',
56 | {
57 | className: classNames({
58 | active: auth.tab == 'login',
59 | }),
60 | },
61 | a(
62 | {
63 | onClick: () =>
64 | effects.auth('tab', 'login'),
65 | },
66 | t`Iniciar sesión`
67 | )
68 | ),
69 | li(
70 | '.tab-item.pointer',
71 | {
72 | className: classNames({
73 | active: auth.tab == 'signup',
74 | }),
75 | },
76 | a(
77 | {
78 | onClick: () =>
79 | effects.auth('tab', 'signup'),
80 | },
81 | t`Crear cuenta`
82 | )
83 | ),
84 | ]
85 | ),
86 | !auth.forgot &&
87 | p('.tc.lh-copy', [
88 | t`Únete o inicia sesión, la conversación te necesita.`,
89 | ]),
90 | div({ style: { padding: '0 0.4rem' } }, [
91 | auth.intent === 'publish' &&
92 | div(
93 | '.toast.toast-warning.mb3',
94 | t`Necesitas estar identificado para continuar con tu publicación.`
95 | ),
96 | ]),
97 | auth.tab === 'login' &&
98 | !auth.forgot &&
99 | h(Login, { state, effects }),
100 | auth.tab === 'login' &&
101 | auth.forgot &&
102 | h(ForgotPassword, { state, effects }),
103 | auth.tab === 'signup' && h(Signup, { state, effects }),
104 | ]
105 | ),
106 | ]),
107 | ]
108 | );
109 | }
110 |
--------------------------------------------------------------------------------
/src/board/components/actions.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import Modal from 'react-modal';
3 | import helpers from 'hyperscript-helpers';
4 | import { Fragment, useState } from 'react';
5 | import { t } from '../../i18n';
6 | import { FlagModal } from './flagModal';
7 | import { BanModal } from './banModal';
8 | import { ChatChannelSettings } from './chatChannelSettingsModal';
9 |
10 | const tags = helpers(h);
11 | const { div, a, form, input } = tags;
12 |
13 | export function ConfirmWithReasonLink(props) {
14 | const [open, setOpen] = useState(false);
15 | const [reason, setReason] = useState('');
16 | function onSubmit(event) {
17 | event.preventDefault();
18 | if (reason.length === 0) {
19 | return;
20 | }
21 | setReason('');
22 | setOpen(false);
23 | props.onConfirm(reason);
24 | }
25 | return h(Fragment, [
26 | a(
27 | '.pointer.post-action',
28 | {
29 | onClick: () => setOpen(true),
30 | },
31 | props.children || []
32 | ),
33 | open === true &&
34 | h(
35 | Modal,
36 | {
37 | isOpen: open,
38 | onRequestClose: () => setOpen(false),
39 | ariaHideApp: false,
40 | contentLabel: props.action || 'Feedback',
41 | className: 'feedback-modal',
42 | style: {
43 | overlay: {
44 | zIndex: 301,
45 | backgroundColor: 'rgba(0, 0, 0, 0.30)',
46 | },
47 | },
48 | },
49 | [
50 | div('.modal-container', { style: { width: '360px' } }, [
51 | props.title && div('.modal-title.mb3', props.title),
52 | form({ onSubmit }, [
53 | div('.form-group', [
54 | input('.form-input', {
55 | onChange: event =>
56 | setReason(event.target.value),
57 | value: reason,
58 | type: 'text',
59 | placeholder:
60 | props.placeholder ||
61 | t`Escribe el motivo de esta acción...`,
62 | required: true,
63 | autoFocus: true,
64 | }),
65 | ]),
66 | input('.btn.btn-primary.btn-block', {
67 | type: 'submit',
68 | disabled: reason.length === 0,
69 | value: props.action || t`Continuar`,
70 | }),
71 | ]),
72 | ]),
73 | ]
74 | ),
75 | ]);
76 | }
77 |
78 | function ToggleableModal({ modal, children, ...props }) {
79 | const [isOpen, setOpen] = useState(false);
80 | return h(Fragment, [
81 | a(
82 | '.pointer.post-action',
83 | {
84 | onClick: () => setOpen(true),
85 | },
86 | children || []
87 | ),
88 | isOpen === true &&
89 | h(modal, {
90 | isOpen,
91 | title: props.title || '',
92 | onRequestClose: () => setOpen(false),
93 | onSend: props.onSend,
94 | ...props,
95 | }),
96 | ]);
97 | }
98 |
99 | export function Flag({ children, ...props }) {
100 | return h(ToggleableModal, { ...props, modal: FlagModal }, children);
101 | }
102 |
103 | export function BanWithReason({ children, ...props }) {
104 | return h(ToggleableModal, { ...props, modal: BanModal }, children);
105 | }
106 |
107 | export function ChatChannelSettingsModal({ children, ...props }) {
108 | return h(
109 | ToggleableModal,
110 | { ...props, modal: ChatChannelSettings },
111 | children
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/src/board/components/author.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import helpers from 'hyperscript-helpers';
3 | import { t, dateToString } from '../../i18n';
4 | import { Link } from 'react-router-dom';
5 | import { differenceInMinutes } from 'date-fns';
6 | import { AuthorOptionsMenu } from './authorOptionsMenu';
7 |
8 | const { div, img, p, time } = helpers(h);
9 |
10 | export function AuthorAvatarLink({ user }) {
11 | return h(Link, { to: `/u/${user.username}/${user.id}`, rel: 'author' }, [
12 | div(
13 | '.dn.db-ns',
14 | {},
15 | user.image
16 | ? img({
17 | src: user.image,
18 | alt: t`Avatar de ${user.username}`,
19 | })
20 | : div('.empty-avatar', {}, user.username.substr(0, 1))
21 | ),
22 | ]);
23 | }
24 |
25 | export function Author({ item, authenticated, ...props }) {
26 | const { author } = item;
27 | const noAvatar = props.noAvatar || false;
28 | const lastSeenAt = author.last_seen_at || false;
29 | const lastSeen = lastSeenAt
30 | ? differenceInMinutes(new Date(), lastSeenAt)
31 | : 1000;
32 | return h('div.flex.items-center', [
33 | noAvatar === false &&
34 | h(AuthorOptionsMenu, { author, lastSeen, authenticated }),
35 | div('.flex-auto.mh1', [
36 | div('.flex-auto', [
37 | h(
38 | Link,
39 | {
40 | to: `/u/${author.username}/${author.id}`,
41 | rel: 'author',
42 | style: { display: 'inline' },
43 | },
44 | [
45 | h('span.b', {}, author.username),
46 | time('.flex-auto.text-right.ml2.text-dark', [
47 | dateToString(item.created_at, 'D MMMM YYYY HH:mm'),
48 | ]),
49 | ]
50 | ),
51 | ]),
52 | author.description && p('.mb0.bio', author.description || ''),
53 | ]),
54 | h('span.flex-auto.text-right', {}, props.children || false),
55 | ]);
56 | }
57 |
--------------------------------------------------------------------------------
/src/board/components/authorOptionsMenu.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import { Link } from 'react-router-dom';
3 | import { t } from '../../i18n';
4 | import helpers from 'hyperscript-helpers';
5 | import classNames from 'classnames';
6 |
7 | const tags = helpers(h);
8 | const { i, figure } = tags;
9 |
10 | export function AuthorOptionsMenu({ author, lastSeen, authenticated }) {
11 | return h('div.popover.popover-right', [
12 | figure('.avatar', [
13 | author.image
14 | ? h('img', {
15 | src: author.image,
16 | alt: `Avatar de ${author.username}`,
17 | })
18 | : h(
19 | 'div.empty-avatar.h-100.flex.items-center.justify-center',
20 | {},
21 | author.username.substr(0, 1)
22 | ),
23 | h('i.avatar-presence', {
24 | className: classNames({
25 | online: lastSeen < 15,
26 | away: lastSeen >= 15 && lastSeen < 30,
27 | }),
28 | }),
29 | ]),
30 | h('div.popover-container.pa0', [
31 | h('ul.menu.h-100', {}, [
32 | h('li.menu-item', {}, [
33 | h(
34 | Link,
35 | {
36 | to: `/u/${author.username}/${author.id}`,
37 | rel: 'author',
38 | onClick: event => event.stopPropagation(),
39 | },
40 | [i('.mr1.icon-user'), t`Ver perfil`]
41 | ),
42 | ]),
43 | authenticated &&
44 | h('li.menu-item', {}, [
45 | h(
46 | Link,
47 | {
48 | to: `/chat/u:${author.id}`,
49 | rel: 'author',
50 | onClick: event => event.stopPropagation(),
51 | },
52 | [i('.mr1.icon-chat-alt'), t`Chat privado`]
53 | ),
54 | ]),
55 | ]),
56 | ]),
57 | ]);
58 | }
59 |
--------------------------------------------------------------------------------
/src/board/components/banModal.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import classNames from 'classnames';
3 | import helpers from 'hyperscript-helpers';
4 | import { useState, useEffect } from 'react';
5 | import { t } from '../../i18n';
6 | import { Modal } from './modal';
7 | import { requestBans } from '../../requests';
8 |
9 | const tags = helpers(h);
10 | const { div, p, form, input, select, option, textarea } = tags;
11 |
12 | export function BanModal({ isOpen, onRequestClose, ...props }) {
13 | const [reason, setReason] = useState('');
14 | const [reasons, setReasons] = useState([]);
15 | const [content, setContent] = useState('');
16 | const [sending, setSending] = useState(false);
17 | const disabled = !reason.length || (reason === 'other' && !content.length);
18 |
19 | // Fetch latest flag reasons and keep them in state.
20 | useEffect(() => {
21 | requestBans().then(setReasons);
22 | }, []);
23 |
24 | async function onSubmit(event) {
25 | event.preventDefault();
26 | if (disabled || sending) {
27 | return;
28 | }
29 | setSending(true);
30 | await Promise.resolve(props.onSend({ reason, content }));
31 | setSending(false);
32 | onRequestClose();
33 | }
34 |
35 | return h(
36 | Modal,
37 | {
38 | isOpen,
39 | onRequestClose,
40 | contentLabel: props.action || 'Feedback',
41 | className: 'feedback-modal',
42 | },
43 | [
44 | div('.modal-container', { style: { width: '360px' } }, [
45 | form('.modal-body', { onSubmit }, [
46 | props.title && p(props.title),
47 | select(
48 | '.form-select.w-100.mb2',
49 | {
50 | value: reason,
51 | onChange: event => setReason(event.target.value),
52 | },
53 | [option({ value: '' }, t`Selecciona un motivo`)].concat(
54 | reasons.map(reason =>
55 | option({ value: reason }, t`${reason}`)
56 | )
57 | )
58 | ),
59 | reason == 'other' &&
60 | div('.form-group', [
61 | textarea('.form-input', {
62 | name: 'description',
63 | placeholder: t`Escribe el motivo...`,
64 | value: content,
65 | onChange: event =>
66 | setContent(event.target.value),
67 | rows: 3,
68 | }),
69 | ]),
70 | input('.btn.btn-primary.btn-block', {
71 | disabled,
72 | type: 'submit',
73 | value: props.action || t`Continuar`,
74 | className: classNames({ loading: sending }),
75 | }),
76 | ]),
77 | ]),
78 | ]
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/src/board/components/chatChannelSettingsModal.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import classNames from 'classnames';
3 | import helpers from 'hyperscript-helpers';
4 | import { useState } from 'react';
5 | import { withRouter } from 'react-router-dom';
6 | import { t } from '../../i18n';
7 | import { Modal } from './modal';
8 |
9 | const tags = helpers(h);
10 | const { div, p, form, input, h3, label, i } = tags;
11 |
12 | export const ChatChannelSettings = withRouter(
13 | function ChatChannelSettings(props) {
14 | const { channel, effects, isOpen, onRequestClose, ...otherProps } =
15 | props;
16 | const [enableYoutubeVideo, setEnableYoutubeVideo] = useState(
17 | !!channel.youtubeVideo
18 | );
19 | const [enableTwitchVideo, setEnableTwitchVideo] = useState(
20 | !!channel.twitchVideo
21 | );
22 | const [deleteChannel, setDeleteChannel] = useState(false);
23 | const [name, setName] = useState(channel.name);
24 | const [description, setDescription] = useState(channel.description);
25 | const [videoId, setVideoId] = useState(channel.youtubeVideo);
26 | const [streamingName, setStreamingName] = useState(channel.twitchVideo);
27 | const disabled =
28 | (name == channel.name || !name) &&
29 | description == channel.description &&
30 | videoId == channel.youtubeVideo &&
31 | streamingName == channel.twitchVideo &&
32 | (enableYoutubeVideo == !!channel.youtubeVideo || !videoId) &&
33 | (enableTwitchVideo == !!channel.twitchVideo || !streamingName) &&
34 | !deleteChannel;
35 |
36 | async function onSubmit(event) {
37 | event.preventDefault();
38 | if (disabled === true) {
39 | return;
40 | }
41 | const updated = {
42 | ...channel,
43 | name,
44 | description,
45 | youtubeVideo: enableYoutubeVideo ? videoId : '',
46 | twitchVideo: enableTwitchVideo ? streamingName : '',
47 | deleted: deleteChannel === true,
48 | };
49 | const state = await effects.updateChatChannelConfig(
50 | channel,
51 | updated
52 | );
53 | props.history.push(
54 | deleteChannel
55 | ? `/chat/${state.site.chat[0].name}`
56 | : `/chat/${name}`
57 | );
58 | onRequestClose();
59 | }
60 | return h(
61 | Modal,
62 | {
63 | isOpen,
64 | onRequestClose,
65 | contentLabel: otherProps.action || 'Feedback',
66 | className: 'chat-config-modal',
67 | },
68 | [
69 | div('.modal-container', { style: { width: '360px' } }, [
70 | form('.modal-body', { onSubmit }, [
71 | div('.flex.items-center.header', [
72 | h3('.flex-auto', t`Configuración del canal`),
73 | ]),
74 | div('.form-group', [
75 | label('.b.form-label', t`Nombre del canal`),
76 | input('.form-input', {
77 | pattern: '^[a-z0-9\\-_]+$',
78 | name: 'name',
79 | type: 'text',
80 | placeholder: t`Ej. Canal-de-Anzu`,
81 | value: name,
82 | onChange: event => setName(event.target.value),
83 | }),
84 | p(
85 | '.form-input-hint',
86 | t`Para el nombre solo se admiten letras, números y guiones `
87 | ),
88 | ]),
89 | div('.form-group', [
90 | label('.b.form-label', t`Desripción del canal`),
91 | input('.form-input', {
92 | maxlenght: '120',
93 | name: 'description',
94 | type: 'text',
95 | placeholder: t`Ej. Este canal se usa para...`,
96 | value: description,
97 | onChange: event =>
98 | setDescription(event.target.value),
99 | }),
100 | p(
101 | '.form-input-hint',
102 | t`Describe cúal es el proposito de tu canal.`
103 | ),
104 | ]),
105 | channel.name !== '' &&
106 | div('.form-group', [
107 | label('.b.form-switch.normal', [
108 | input({
109 | type: 'checkbox',
110 | onChange: event =>
111 | setDeleteChannel(
112 | event.target.checked
113 | ),
114 | checked: deleteChannel,
115 | }),
116 | i('.form-icon'),
117 | t`Borrar canal`,
118 | ]),
119 | ]),
120 | deleteChannel === false &&
121 | enableTwitchVideo === false &&
122 | div('.form-group', [
123 | label('.b.form-switch.normal', [
124 | input({
125 | type: 'checkbox',
126 | onChange: event =>
127 | setEnableYoutubeVideo(
128 | event.target.checked
129 | ),
130 | checked: enableYoutubeVideo,
131 | }),
132 | i('.form-icon'),
133 | t`Video de Youtube`,
134 | ]),
135 | ]),
136 | deleteChannel === false &&
137 | enableYoutubeVideo === true &&
138 | div('.form-group', [
139 | label('.b.form-label', t`ID del video`),
140 | input('.form-input', {
141 | name: 'videoId',
142 | type: 'text',
143 | placeholder: t`ID del video`,
144 | value: videoId,
145 | onChange: event =>
146 | setVideoId(event.target.value),
147 | }),
148 | p(
149 | '.form-input-hint',
150 | t`Mostrado alrededor del sitio, el nombre de tu comunidad.`
151 | ),
152 | ]),
153 | deleteChannel === false &&
154 | enableYoutubeVideo === false &&
155 | div('.form-group', [
156 | label('.b.form-switch.normal', [
157 | input({
158 | type: 'checkbox',
159 | onChange: event =>
160 | setEnableTwitchVideo(
161 | event.target.checked
162 | ),
163 | checked: enableTwitchVideo,
164 | }),
165 | i('.form-icon'),
166 | t`Directo de twitch`,
167 | ]),
168 | ]),
169 | deleteChannel === false &&
170 | enableTwitchVideo === true &&
171 | div('.form-group', [
172 | label('.b.form-label', t`Nombre del canal`),
173 | input('.form-input', {
174 | name: 'streamingName',
175 | type: 'text',
176 | placeholder: t`Nombre del canal`,
177 | value: streamingName,
178 | onChange: event =>
179 | setStreamingName(event.target.value),
180 | }),
181 | p(
182 | '.form-input-hint',
183 | t`Mostrado alrededor del sitio, el nombre de tu comunidad.`
184 | ),
185 | ]),
186 | input('.btn.btn-block', {
187 | disabled,
188 | type: 'submit',
189 | value: deleteChannel
190 | ? 'Borrar Canal'
191 | : 'Guardar Configuración',
192 | className: classNames({
193 | 'btn-primary': true,
194 | 'btn-error': deleteChannel === true,
195 | }),
196 | }),
197 | ]),
198 | ]),
199 | ]
200 | );
201 | }
202 | );
203 |
--------------------------------------------------------------------------------
/src/board/components/chatLogItem.js:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { format } from 'date-fns';
3 | import { t, translate } from '../../i18n';
4 | import h from 'react-hyperscript';
5 | import helpers from 'hyperscript-helpers';
6 |
7 | const { div, small } = helpers(h);
8 |
9 | export const ChatLogItem = memo(function ({ message }) {
10 | const i18nParams = message.i18n || [];
11 | const translated = i18nParams.map(item => t`${item}`);
12 | return div('.tile.mb2.ph3.log', { key: message.id }, [
13 | div('.tile-icon', { style: { width: '2rem' } }, [
14 | small('.time', [format(message.at, 'HH:mm')]),
15 | ]),
16 | div('.tile-content', [
17 | div('.tile-title', [
18 | small('.text-bold.text-gray', t`System message:`),
19 | ]),
20 | div(
21 | '.tile-subtitle.mb1.text-small',
22 | translate`${message.msg}`.fetch(...translated)
23 | ),
24 | ]),
25 | ]);
26 | });
27 |
--------------------------------------------------------------------------------
/src/board/components/chatMessageInput.js:
--------------------------------------------------------------------------------
1 | import { useState, memo } from 'react';
2 | import Tribute from '../../drivers/tribute';
3 | import h from 'react-hyperscript';
4 | import helpers from 'hyperscript-helpers';
5 | import { t } from '../../i18n';
6 | import { glueEvent } from '../utils';
7 | import { requestMentionable } from '../../requests';
8 |
9 | const { a, div, input, form, p } = helpers(h);
10 |
11 | async function fetchUsers(query, callback) {
12 | if (!query) return;
13 | const users = await requestMentionable(query);
14 | callback(users.map(user => ({ name: user.Username, id: user.Username })));
15 | }
16 |
17 | export const ChatMessageInput = memo(function ({ state, effects, chan }) {
18 | const [message, setMessage] = useState('');
19 | function onSubmit(event) {
20 | event.preventDefault();
21 | if (message === '') {
22 | return;
23 | }
24 | state.realtime.send(glueEvent('chat:message', { msg: message, chan }));
25 | setMessage('');
26 | }
27 | return form('.pa3', { onSubmit }, [
28 | !state.authenticated &&
29 | div('.flex.flex-wrap.mb3', [
30 | p('.mb0.mh-auto', [
31 | t`Para utilizar el chat `,
32 | a(
33 | '.link.modal-link.pointer',
34 | {
35 | onClick: () =>
36 | effects.auth({
37 | modal: true,
38 | tab: 'login',
39 | }),
40 | },
41 | t`inicia sesión`
42 | ),
43 | t`, o si aún no tienes una cuenta, `,
44 | a(
45 | '.link.modal-link.pointer',
46 | {
47 | onClick: () =>
48 | effects.auth({
49 | modal: true,
50 | tab: 'signup',
51 | }),
52 | },
53 | t`registrate`
54 | ),
55 | ]),
56 | ]),
57 | h(
58 | Tribute,
59 | {
60 | onChange: event => setMessage(event.target.value),
61 | options: {
62 | collection: [
63 | {
64 | values: fetchUsers,
65 | lookup: 'name',
66 | fillAttr: 'name',
67 | },
68 | ],
69 | },
70 | },
71 | [
72 | input('.form-input', {
73 | disabled: false === state.authenticated,
74 | placeholder: t`Escribe aquí tu mensaje...`,
75 | value: message,
76 | type: 'text',
77 | autoFocus: true,
78 | onChange: event => setMessage(event.target.value),
79 | }),
80 | ]
81 | ),
82 | ]);
83 | });
84 |
--------------------------------------------------------------------------------
/src/board/components/chatMessageItem.js:
--------------------------------------------------------------------------------
1 | import { useContext, memo } from 'react';
2 | import { format } from 'date-fns';
3 | import classNames from 'classnames';
4 | import { t } from '../../i18n';
5 | import h from 'react-hyperscript';
6 | import helpers from 'hyperscript-helpers';
7 | import { Link } from 'react-router-dom';
8 | import { AuthContext } from '../fractals/auth';
9 | import { adminTools } from '../../acl';
10 | import { glueEvent, MemoizedBasicMarkdown } from '../utils';
11 | import { Flag } from './actions';
12 |
13 | const { a, div, small, i, figure, span, img } = helpers(h);
14 |
15 | export const ChatMessageItem = memo(function (props) {
16 | const auth = useContext(AuthContext);
17 | const { message, short, isOnline, bottomRef, lockRef, chan, effects } =
18 | props;
19 | function onImageLoad() {
20 | if (lockRef.current) {
21 | return;
22 | }
23 | window.requestAnimationFrame(() => {
24 | bottomRef.current.scrollIntoView({});
25 | });
26 | }
27 | const initial = message.from.substr(0, 2).toUpperCase();
28 | return div('.tile.mb2.ph3', [
29 | div('.tile-icon', { style: { width: '2rem' } }, [
30 | !short &&
31 | figure('.avatar', { dataset: { initial } }, [
32 | message.avatar && img({ src: message.avatar }),
33 | i('.avatar-presence', {
34 | className: classNames({
35 | online: isOnline,
36 | }),
37 | }),
38 | ]),
39 | short && small('.time', [format(message.at, 'HH:mm')]),
40 | ]),
41 | div('.tile-content', [
42 | !short &&
43 | div('.tile-title.pt2.mb2', [
44 | h(
45 | Link,
46 | { to: `/u/${message.from}/${message.userId}` },
47 | span('.text-bold.text-primary', message.from)
48 | ),
49 | span('.text-gray.ml2', format(message.at, 'HH:mm')),
50 | ]),
51 | div('.tile-subtitle', {}, [
52 | h(MemoizedBasicMarkdown, { content: message.msg, onImageLoad }),
53 | ]),
54 | ]),
55 | div('.tile-actions.self-center', {}, [
56 | adminTools({ user: auth.auth.user }) &&
57 | a(
58 | {
59 | onClick: () =>
60 | auth.glue.send(
61 | glueEvent('chat:star', {
62 | chan,
63 | message,
64 | id: message.id,
65 | })
66 | ),
67 | },
68 | [i('.mr1.icon-star-filled.pointer', { title: t`Destacar` })]
69 | ),
70 | adminTools({ user: auth.auth.user }) &&
71 | a(
72 | {
73 | onClick: () =>
74 | auth.glue.send(
75 | glueEvent('chat:ban', {
76 | userId: message.userId,
77 | id: message.id,
78 | })
79 | ),
80 | },
81 | [
82 | i('.mr1.icon-cancel-circled.pointer', {
83 | title: t`Banear usuario`,
84 | }),
85 | ]
86 | ),
87 | !auth.canUpdate(message.userId) &&
88 | h(
89 | Flag,
90 | {
91 | title: t`Reportar un mensaje`,
92 | message,
93 | onSend: form =>
94 | effects.requestFlag({
95 | ...form,
96 | related_id: message.id,
97 | related_to: 'chat',
98 | }),
99 | },
100 | i('.mr1.icon-warning-empty.pointer', { title: t`Reportar` })
101 | ),
102 | auth.canUpdate(message.userId) &&
103 | h(
104 | 'a',
105 | {
106 | onClick: () =>
107 | auth.glue.send(
108 | glueEvent('chat:delete', {
109 | reason: 'Message deleted by admin',
110 | chan,
111 | id: message.id,
112 | })
113 | ),
114 | },
115 | i('.mr1.icon-trash', { title: t`Borrar mensaje` })
116 | ),
117 | ]),
118 | ]);
119 | });
120 |
--------------------------------------------------------------------------------
/src/board/components/chatMessageList.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState, memo } from 'react';
2 | import { useTitleNotification } from '../../hooks';
3 | import classNames from 'classnames';
4 | import h from 'react-hyperscript';
5 | import helpers from 'hyperscript-helpers';
6 | import { MemoizedBasicMarkdown } from '../utils';
7 | import { format, subSeconds, isAfter } from 'date-fns';
8 | import { streamChatChannel } from '../../streams';
9 | import { ChatLogItem } from './chatLogItem';
10 | import { ChatMessageItem } from './chatMessageItem';
11 |
12 | const tags = helpers(h);
13 | const { div, i } = tags;
14 | const { figure, img } = tags;
15 | const { h5, span } = tags;
16 |
17 | export const ChatMessageList = memo(function (props) {
18 | const { state, channel, isOnline, lockRef, effects, soundRef } = props;
19 | const bottomRef = useRef(null);
20 | const [list, setList] = useState([]);
21 | const [featured, setFeatured] = useState(false);
22 | const [, { pingNotification }] = useTitleNotification({ type: 'chat' });
23 | const [loading, setLoading] = useState(false);
24 | const chan = channel.name;
25 | useEffect(() => {
26 | // This side effect will be executed every time a channel is load.
27 | // So in short it clears the message list state and subscribes to
28 | // the events stream.
29 | setList([]);
30 | setFeatured(false);
31 | setLoading(true);
32 | // Reactive message list from our chat source.
33 | const dispose = streamChatChannel(
34 | { realtime: state.realtime, chan },
35 | ({ list, starred }) => {
36 | setList(lockRef.current ? list : list.slice(-50));
37 | setFeatured(starred.length > 0 && starred[0]);
38 | setLoading(false);
39 | if (soundRef.current === false) {
40 | pingNotification();
41 | }
42 | if (lockRef.current) {
43 | return;
44 | }
45 | window.requestAnimationFrame(() => {
46 | if (bottomRef.current) {
47 | bottomRef.current.scrollIntoView({});
48 | }
49 | });
50 | }
51 | );
52 | // Unsubscribe will be called at unmount.
53 | return dispose;
54 | }, [chan]);
55 |
56 | return div('.flex-auto.overflow-y-scroll.relative', [
57 | loading && div('.loading.loading-lg.mt2'),
58 | featured &&
59 | isAfter(featured.at, subSeconds(new Date(), 15)) &&
60 | div(
61 | '.starred',
62 | {},
63 | div([
64 | h5([i('.icon-star-filled.mr1'), 'Mensaje destacado:']),
65 | div('.tile', [
66 | div('.tile-icon', { style: { width: '2rem' } }, [
67 | figure('.avatar', [
68 | featured.avatar &&
69 | img({ src: featured.avatar }),
70 | i('.avatar-presence', {
71 | className: classNames({
72 | online: isOnline,
73 | }),
74 | }),
75 | ]),
76 | ]),
77 | div('.tile-content', [
78 | div('.tile-title.mb2', [
79 | span('.text-bold.text-primary', featured.from),
80 | span(
81 | '.text-gray.ml2',
82 | format(featured.at, 'HH:mm')
83 | ),
84 | ]),
85 | div('.tile-subtitle', {}, [
86 | h(MemoizedBasicMarkdown, {
87 | content: featured.msg,
88 | onImageLoad: () => false,
89 | }),
90 | ]),
91 | ]),
92 | ]),
93 | ])
94 | ),
95 | div(
96 | '.pv3',
97 | list.map((message, k) => {
98 | if (message.type === 'log') {
99 | return h(ChatLogItem, {
100 | key: message.id,
101 | message,
102 | });
103 | }
104 |
105 | return h(ChatMessageItem, {
106 | key: message.id,
107 | short:
108 | list[k - 1] &&
109 | list[k - 1].from === message.from &&
110 | (!list[k - 10] ||
111 | k % 10 != 0 ||
112 | (list[k - 10] &&
113 | list[k - 10].from !== message.from)),
114 | message,
115 | isOnline: isOnline && isOnline.has(message.userId),
116 | bottomRef,
117 | effects,
118 | lockRef,
119 | chan,
120 | });
121 | })
122 | ),
123 | div('#bottom', { ref: bottomRef }),
124 | ]);
125 | });
126 |
--------------------------------------------------------------------------------
/src/board/components/chatNavSidebar.js:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { Link } from 'react-router-dom';
3 | import h from 'react-hyperscript';
4 | import helpers from 'hyperscript-helpers';
5 | import { t } from '../../i18n';
6 | import { adminTools } from '../../acl';
7 | import { ChatChannelSettingsModal } from '../components/actions';
8 |
9 | const tags = helpers(h);
10 | const { div, i, h1, h4, span, header, nav, a, section } = tags;
11 |
12 | export function ChatNavSidebar(props) {
13 | const { channels, active, auth, effects, counters, online } = props;
14 | return div('#channels.flex-shrink-0.flex-ns.flex-column.dn', [
15 | div('.flex-auto.flex.flex-column', [
16 | header('.flex.items-center.ph3', [h1('.f5.dib.v-mid', t`Canales`)]),
17 | nav(
18 | '.flex-auto',
19 | channels
20 | .map(channel =>
21 | h(
22 | Link,
23 | {
24 | key: channel.name,
25 | to: `/chat/${channel.name}`,
26 | className: classNames('db pa2 ph3', {
27 | active: channel.name === active,
28 | }),
29 | },
30 | `#${channel.name}`
31 | )
32 | )
33 | .concat([
34 | adminTools({ user: auth.auth.user }) &&
35 | h('div.tc.mt2', {}, [
36 | h(
37 | ChatChannelSettingsModal,
38 | {
39 | channel: {
40 | name: '',
41 | description: '',
42 | youtubeVideo: '',
43 | twitchVideo: '',
44 | },
45 | effects,
46 | },
47 | [
48 | span(
49 | '.btn.btn-sm.btn-primary',
50 | t`Agregar Canal`
51 | ),
52 | ]
53 | ),
54 | ]),
55 | ])
56 | ),
57 | section('.flex.flex-column.peers', [
58 | header('.ph3.pv2', [
59 | h4('.dib.v-mid.mb0', t`Conectados`),
60 | a('.dib.btn-icon.ml4.dropdown-toggle', {}, [
61 | span('.bg-green.br-100.dib.mr1', {
62 | style: { width: 10, height: 10 },
63 | }),
64 | span('.online.b', String(online)),
65 | ]),
66 | ]),
67 | nav(
68 | '.flex-auto.overflow-y-auto',
69 | (counters.peers || []).map(([id, username]) =>
70 | h(
71 | Link,
72 | {
73 | key: id,
74 | to: `/chat/u:${id}`,
75 | className: classNames('db pa2 ph3', {
76 | active: `u:${id}` === active,
77 | }),
78 | },
79 | [i('.icon-user.mr2'), `${username}`]
80 | )
81 | )
82 | ),
83 | ]),
84 | ]),
85 | ]);
86 | }
87 |
--------------------------------------------------------------------------------
/src/board/components/chatVideoPlayer.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import helpers from 'hyperscript-helpers';
3 | import YouTube from 'react-youtube';
4 | import Twitch from 'react-twitch-embed-video';
5 |
6 | const tags = helpers(h);
7 | const { div } = tags;
8 |
9 | export function ChatVideoPlayer({ channel }) {
10 | return div(
11 | '.ph3#video',
12 | {
13 | style: {
14 | minWidth: 515,
15 | zIndex: 100,
16 | top: 75,
17 | right: 35,
18 | },
19 | },
20 | [
21 | channel.youtubeVideo != '' &&
22 | h(
23 | '.video-responsive.center',
24 | { style: { maxWidth: '70%' } },
25 | h(YouTube, {
26 | videoId: channel.youtubeVideo,
27 | opts: {
28 | playerVars: {
29 | // https://developers.google.com/youtube/player_parameters
30 | autoplay: 0,
31 | },
32 | },
33 | })
34 | ),
35 | channel.twitchVideo != '' &&
36 | h(
37 | '.video-responsive.center',
38 | { style: { maxWidth: '70%' } },
39 | h(Twitch, {
40 | channel: channel.twitchVideo,
41 | layout: 'video',
42 | muted: false,
43 | targetClass: 'twitch-embed',
44 | })
45 | ),
46 | ]
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/board/components/configModal.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import h from 'react-hyperscript';
3 | import Modal from 'react-modal';
4 | import helpers from 'hyperscript-helpers';
5 | import { t } from '../../i18n';
6 | import { GeneralConfig } from './generalConfig';
7 | import { CategoriesConfig } from './categoriesConfig';
8 | import classNames from 'classnames';
9 |
10 | const tags = helpers(h);
11 | const { div, img, i, a } = tags;
12 |
13 | export function ConfigModal({ state, setOpen, effects }) {
14 | const [activeTab, setActiveTab] = useState('general');
15 |
16 | return h(
17 | Modal,
18 | {
19 | isOpen: true,
20 | onRequestClose: () => setOpen(false),
21 | ariaHideApp: false,
22 | contentLabel: t`Configuración`,
23 | className: 'config-modal',
24 | style: {
25 | overlay: {
26 | zIndex: 301,
27 | backgroundColor: 'rgba(0, 0, 0, 0.30)',
28 | },
29 | },
30 | },
31 | div('.modal-container.config.fade-in.', { style: { width: '640px' } }, [
32 | div('.flex', [
33 | h('nav', [
34 | a([
35 | img('.w3', {
36 | src: '/images/anzu.svg',
37 | alt: 'Anzu',
38 | }),
39 | ]),
40 | a(
41 | {
42 | onClick: () => setActiveTab('general'),
43 | className: classNames({
44 | active: activeTab === 'general',
45 | }),
46 | },
47 | [i('.icon-cog.mr1'), t`General`]
48 | ),
49 | a(
50 | {
51 | onClick: () => setActiveTab('categories'),
52 | className: classNames({
53 | active: activeTab === 'categories',
54 | }),
55 | },
56 | [i('.icon-th-list-outline.mr1'), t`Categorías`]
57 | ),
58 | a([i('.icon-lock-open.mr1'), t`Permisos`]),
59 | a([i('.icon-picture-outline.mr1'), t`Diseño`]),
60 | ]),
61 | div([
62 | activeTab === 'general' &&
63 | h(GeneralConfig, { state, setOpen, effects }),
64 | activeTab === 'categories' &&
65 | h(CategoriesConfig, { state, setOpen, effects }),
66 | ]),
67 | ]),
68 | ])
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/board/components/feed.js:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import h from 'react-hyperscript';
3 | import classNames from 'classnames';
4 | import { i18n, t, ago } from '../../i18n';
5 | import { FeedCategories } from './feedCategories';
6 | import { Link } from 'react-router-dom';
7 | import { withRouter } from 'react-router-dom';
8 | import { throttle } from 'lodash';
9 | import { differenceInMinutes } from 'date-fns';
10 | import { AuthorOptionsMenu } from './authorOptionsMenu';
11 | import { getLocalValue } from '../utils';
12 |
13 | const throttledFeedScroll = throttle(function onScroll(bottomReached, effects) {
14 | if (bottomReached) {
15 | effects.fetchMorePosts();
16 | }
17 | }, 200);
18 |
19 | export function Feed({ state, effects }) {
20 | const { feed, subcategories, categories, authenticated } = state;
21 | const { list } = feed;
22 | function onScroll(e) {
23 | const bottomReached =
24 | e.target.scrollHeight - e.target.scrollTop - e.target.clientHeight <
25 | 1;
26 | throttledFeedScroll(bottomReached, effects);
27 | }
28 | return h('section.fade-in.feed.flex.flex-column', [
29 | h('section.tabs', [
30 | h(FeedCategories, { feed, categories, subcategories }),
31 | h('div.filters.flex', [
32 | h('div.flex-auto', [
33 | h('nav', [
34 | h(
35 | 'a.pointer',
36 | {
37 | onClick: () => effects.setTab(false),
38 | className: classNames({
39 | active: feed.relevant === false,
40 | }),
41 | },
42 | t`Recientes`
43 | ),
44 | h(
45 | 'a.pointer',
46 | {
47 | onClick: () => effects.setTab(true),
48 | className: classNames({
49 | active: feed.relevant,
50 | }),
51 | },
52 | t`Populares`
53 | ),
54 | ]),
55 | ]),
56 | h('div.pl3', [
57 | h(
58 | Link,
59 | {
60 | to: '/publicar',
61 | className: 'btn btn-sm btn-primary dib dn-ns',
62 | },
63 | t`Publicar`
64 | ),
65 | h(
66 | Link,
67 | {
68 | to: '/publicar',
69 | className: 'btn btn-sm btn-primary dn dib-ns',
70 | },
71 | t`Crear publicación`
72 | ),
73 | ]),
74 | ]),
75 | h(
76 | 'div.new-posts.shadow.toast.toast-success',
77 | { className: classNames({ dn: state.counters.posts == 0 }) },
78 | [
79 | h(
80 | 'a.load-more',
81 | { onClick: () => effects.fetchRecentPosts() },
82 | i18n
83 | .translate('Cargar nueva publicación')
84 | .ifPlural(
85 | state.counters.posts,
86 | 'Cargar %d nuevas publicaciones.'
87 | )
88 | .fetch(state.counters.posts)
89 | ),
90 | h('span.icon-cancel.fr'),
91 | ]
92 | ),
93 | ]),
94 | h(
95 | 'section.list.flex-auto',
96 | { onScroll },
97 | list
98 | .map(post => {
99 | const localDraft = getLocalValue(
100 | `anzu.markdown.post.${post.id}`
101 | );
102 | const draft = localDraft && JSON.parse(localDraft);
103 | const hasDraft =
104 | draft && draft.value && draft.value.length > 0;
105 | return h(MemoFeedItem, {
106 | key: post.id,
107 | authenticated,
108 | post,
109 | hasDraft,
110 | active: state.post.id === post.id,
111 | subcategories,
112 | recent:
113 | state.counters.recent[post.id] ||
114 | post.comments.count,
115 | missed: state.counters.missed[post.id] || 0,
116 | acknowledged: state.counters.acknowledged[post.id] || 0,
117 | });
118 | })
119 | .concat([
120 | feed.endReached &&
121 | h('div.pv2.ph3.tc', {}, [
122 | h(
123 | 'p.measure.center.ph2.text-gray.lh-copy.tc',
124 | t`No encontramos más publicaciones por cargar en este momento.`
125 | ),
126 | h(
127 | 'a.btn.btn-sm.btn-primary',
128 | { onClick: () => effects.fetchMorePosts() },
129 | h('i.icon-arrows-cw')
130 | ),
131 | ]),
132 |
133 | h(
134 | 'div.pv2',
135 | { style: { minHeight: 50 } },
136 | h('div.loading', {
137 | className: classNames({
138 | dn: !feed.loading,
139 | }),
140 | })
141 | ),
142 | ])
143 | ),
144 | ]);
145 | }
146 |
147 | const MemoFeedItem = memo(withRouter(FeedItem));
148 |
149 | function FeedItem(props) {
150 | const {
151 | post,
152 | subcategories,
153 | recent,
154 | active,
155 | history,
156 | hasDraft,
157 | authenticated,
158 | } = props;
159 | const { author } = post;
160 | const { count } = post.comments;
161 | const href = `/p/${post.slug}/${post.id}`;
162 | const category = subcategories.id[post.category] || false;
163 | const missed = recent - count;
164 | const lastSeenAt = author.last_seen_at || false;
165 | const lastSeen = lastSeenAt
166 | ? differenceInMinutes(new Date(), lastSeenAt)
167 | : 1000;
168 |
169 | return h(
170 | 'article.post',
171 | {
172 | onClick: () => history.push(href),
173 | className: classNames({
174 | active,
175 | }),
176 | },
177 | [
178 | h('div.flex.items-center', [
179 | h(
180 | 'div.flex-auto',
181 | {},
182 | category != false &&
183 | h(
184 | Link,
185 | {
186 | className: 'category',
187 | to: `/c/${category.slug}`,
188 | onClick: event => event.stopPropagation(),
189 | },
190 | category.name
191 | )
192 | ),
193 | hasDraft && h('i.icon-doc-text', {}, t`Borrador`),
194 | post.pinned && h('span.icon-pin.pinned-post'),
195 | ]),
196 | h('div.flex.items-center', [
197 | h('div.flex-auto', [
198 | h(
199 | 'h1',
200 | {},
201 | h(
202 | Link,
203 | {
204 | to: href,
205 | className: 'link',
206 | },
207 | post.title
208 | )
209 | ),
210 | h('div.author', [
211 | h(AuthorOptionsMenu, {
212 | author,
213 | lastSeen,
214 | authenticated,
215 | }),
216 | h('div', [
217 | h(
218 | Link,
219 | {
220 | to: `/u/${author.username}/${author.id}`,
221 | rel: 'author',
222 | style: { display: 'inline' },
223 | onClick: event => event.stopPropagation(),
224 | },
225 | h('span', {}, author.username)
226 | ),
227 | h(
228 | 'span.ago.f7',
229 | {},
230 | t`hace` + ' ' + ago(post.created_at)
231 | ),
232 | ]),
233 | ]),
234 | ]),
235 | h(
236 | 'div.tc',
237 | {
238 | style: {
239 | minWidth: 60,
240 | textAlign: 'right',
241 | flexShrink: 0,
242 | flexGrow: 1,
243 | },
244 | },
245 | [
246 | h('span.icon-chat-alt'),
247 | h('span.pl2.b', {}, count),
248 | missed > 0 && h('span.new-comments', {}, `+${missed}`),
249 | ]
250 | ),
251 | ]),
252 | ]
253 | );
254 | }
255 |
--------------------------------------------------------------------------------
/src/board/components/feedCategories.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import { Link } from 'react-router-dom';
3 | import { memo } from 'react';
4 | import { t } from '../../i18n';
5 |
6 | export const FeedCategories = memo(props => {
7 | const { feed, categories, subcategories } = props;
8 | const { category } = feed;
9 | const slugs = subcategories.slug || {};
10 | const list = categories || [];
11 | const menu = list.reduce((all, current) => {
12 | return all
13 | .concat(
14 | h('div.divider', {
15 | key: current.name,
16 | 'data-content': current.name,
17 | })
18 | )
19 | .concat(
20 | current.subcategories.map(s =>
21 | h(
22 | 'li.menu-item',
23 | { key: s.slug },
24 | h(Link, { to: '/c/' + s.slug }, s.name)
25 | )
26 | )
27 | );
28 | }, []);
29 |
30 | return h(
31 | 'div.categories.flex.items-center',
32 | (category !== false && category in slugs) || feed.search.length > 0
33 | ? [
34 | h(
35 | Link,
36 | {
37 | className: 'dib btn-icon',
38 | to: '/',
39 | tabIndex: 0,
40 | },
41 | h('span.icon-left-open')
42 | ),
43 | h(
44 | 'h2.pl2.flex-auto.fade-in',
45 | feed.search.length > 0
46 | ? t`Buscando: ${feed.search}`
47 | : slugs[category].name
48 | ),
49 | feed.loading && h('span.loading.mr4'),
50 | ]
51 | : [
52 | h('h2.flex-auto.fade-in', t`Todas las categorias`),
53 | feed.loading && h('span.loading.mr4'),
54 | h('div.dropdown.dropdown-right.fade-in', [
55 | h(
56 | 'a.dib.btn-icon.dropdown-toggle',
57 | { tabIndex: 0 },
58 | h('span.icon-down-open')
59 | ),
60 | h('ul.menu', menu),
61 | ]),
62 | ]
63 | );
64 | });
65 |
--------------------------------------------------------------------------------
/src/board/components/flagModal.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import classNames from 'classnames';
3 | import helpers from 'hyperscript-helpers';
4 | import { useState, useEffect } from 'react';
5 | import { t } from '../../i18n';
6 | import { Modal } from './modal';
7 | import { requestFlags } from '../../requests';
8 |
9 | const tags = helpers(h);
10 | const { div, p, form, input, select, option, textarea } = tags;
11 |
12 | export function FlagModal({ isOpen, onRequestClose, ...props }) {
13 | const [reason, setReason] = useState('');
14 | const [reasons, setReasons] = useState([]);
15 | const [content, setContent] = useState('');
16 | const [sending, setSending] = useState(false);
17 | const disabled = !reason.length || (reason === 'other' && !content.length);
18 |
19 | // Fetch latest flag reasons and keep them in state.
20 | useEffect(() => {
21 | requestFlags().then(setReasons);
22 | }, []);
23 |
24 | async function onSubmit(event) {
25 | event.preventDefault();
26 | if (disabled || sending) {
27 | return;
28 | }
29 | setSending(true);
30 | await Promise.resolve(props.onSend({ reason, content }));
31 | setSending(false);
32 | onRequestClose();
33 | }
34 |
35 | return h(
36 | Modal,
37 | {
38 | isOpen,
39 | onRequestClose,
40 | contentLabel: props.action || 'Feedback',
41 | className: 'feedback-modal',
42 | },
43 | [
44 | div('.modal-container', { style: { width: '360px' } }, [
45 | form('.modal-body', { onSubmit }, [
46 | props.title && p(props.title),
47 | select(
48 | '.form-select.w-100.mb2',
49 | {
50 | value: reason,
51 | onChange: event => setReason(event.target.value),
52 | },
53 | [option({ value: '' }, t`Selecciona un motivo`)].concat(
54 | reasons.map(reason =>
55 | option({ value: reason }, t`${reason}`)
56 | )
57 | )
58 | ),
59 | reason == 'other' &&
60 | div('.form-group', [
61 | textarea('.form-input', {
62 | name: 'description',
63 | placeholder: t`Escribe el motivo...`,
64 | value: content,
65 | onChange: event =>
66 | setContent(event.target.value),
67 | rows: 3,
68 | }),
69 | ]),
70 | input('.btn.btn-primary.btn-block', {
71 | disabled,
72 | type: 'submit',
73 | value: props.action || 'Continuar',
74 | className: classNames({ loading: sending }),
75 | }),
76 | ]),
77 | ]),
78 | ]
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/src/board/components/forgotPassword.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import helpers from 'hyperscript-helpers';
3 | import classNames from 'classnames';
4 | import { t } from '../../i18n';
5 |
6 | const tags = helpers(h);
7 | const { div, form, a, input, p } = tags;
8 |
9 | export function ForgotPassword({ state, effects }) {
10 | const { auth } = state;
11 |
12 | function lostPasswordHandler(event) {
13 | event.preventDefault();
14 | if (auth.loading) {
15 | return;
16 | }
17 | effects.requestPasswordReset();
18 | }
19 |
20 | return div(
21 | '.content.fade-in',
22 | { key: 'forgot-password', style: { padding: '0 0.4rem 0.5rem' } },
23 | [
24 | form({ onSubmit: lostPasswordHandler }, [
25 | h('h6', { className: 'mb2' }, t`Recuperar contraseña`),
26 | p(
27 | t`Recupera el acceso a tu cuenta proporcionando el correo electrónico que usaste en tu registro.`
28 | ),
29 | div(
30 | '.bg-error.pa2.mb2.f7.fade-in',
31 | { className: classNames({ dn: auth.error === false }) },
32 | t`${auth.error}`
33 | ),
34 | div('.form-group', [
35 | input('.form-input', {
36 | onChange: event =>
37 | effects.auth('email', event.target.value),
38 | value: auth.email,
39 | id: 'email',
40 | type: 'email',
41 | autoComplete: 'username',
42 | placeholder: t`Correo electrónico`,
43 | required: true,
44 | autoFocus: true,
45 | }),
46 | ]),
47 | input('.btn.btn-primary.btn-block', {
48 | type: 'submit',
49 | value: t`Recuperar contraseña`,
50 | className: classNames({ loading: auth.loading }),
51 | }),
52 | a(
53 | '.db.link.tc.mt2',
54 | {
55 | id: 'forgot',
56 | onClick: () => effects.auth('forgot', false),
57 | },
58 | t`Cancelar`
59 | ),
60 | ]),
61 | ]
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/board/components/generalConfig.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import h from 'react-hyperscript';
3 | import helpers from 'hyperscript-helpers';
4 | import { t } from '../../i18n';
5 |
6 | const tags = helpers(h);
7 | const { div, i, a, p } = tags;
8 | const { span, h2, form, input, label, textarea } = tags;
9 |
10 | export function GeneralConfig({ state, setOpen, effects }) {
11 | const { site } = state;
12 | const [nav, setNav] = useState(site.nav);
13 | const [changes, setChanges] = useState({});
14 | const dirty = Object.keys(changes).length > 0 || nav !== site.nav;
15 |
16 | function swapNavLink(from, to) {
17 | const a = nav[from];
18 | const b = nav[to];
19 | const copy = nav.slice();
20 | copy[from] = b;
21 | copy[to] = a;
22 | setNav(copy);
23 | }
24 |
25 | function newNavLink() {
26 | const link = {
27 | name: '',
28 | href: '',
29 | };
30 | setNav(nav.concat(link));
31 | }
32 |
33 | function deleteNavLink(index) {
34 | const filtered = nav.filter((_, k) => k !== index);
35 | setNav(filtered);
36 | }
37 |
38 | function updateNavLink(index, field, value) {
39 | const link = nav[index];
40 | const copy = nav.slice();
41 | copy[index] = {
42 | ...link,
43 | [field]: value,
44 | };
45 | setNav(copy);
46 | }
47 |
48 | function onSubmit(event) {
49 | event.preventDefault();
50 | const config = {
51 | ...changes,
52 | nav,
53 | };
54 | effects.updateSiteConfig(config);
55 | setOpen(false);
56 | }
57 |
58 | function cancelConfigEdition() {
59 | setChanges({});
60 | setNav(site.nav);
61 | }
62 |
63 | return form('.flex-auto.pa3.overflow-container', { onSubmit }, [
64 | div([
65 | div('.flex.header.justify-end', [
66 | h2('.flex-auto', t`General`),
67 | dirty === true &&
68 | span('.fixed.z-9999', [
69 | span([
70 | input('.btn.btn-primary.mr2', {
71 | type: 'submit',
72 | value: t`Guardar cambios`,
73 | }),
74 | ]),
75 | span([
76 | input('.btn', {
77 | type: 'button',
78 | value: t`Cancelar`,
79 | onClick: cancelConfigEdition,
80 | }),
81 | ]),
82 | ]),
83 | ]),
84 | div('.form-group', [
85 | label('.b.form-label', t`Nombre del sitio`),
86 | input('.form-input', {
87 | name: 'name',
88 | type: 'text',
89 | placeholder: t`Ej. Comunidad de Anzu`,
90 | required: true,
91 | value: 'name' in changes ? changes.name : site.name,
92 | onChange: event =>
93 | setChanges({
94 | ...changes,
95 | name: event.target.value,
96 | }),
97 | }),
98 | p(
99 | '.form-input-hint',
100 | t`Mostrado alrededor del sitio, el nombre de tu comunidad.`
101 | ),
102 | ]),
103 | div('.form-group', [
104 | label('.b.form-label', t`Descripción del sitio`),
105 | textarea('.form-input', {
106 | value:
107 | 'description' in changes
108 | ? changes.description
109 | : site.description,
110 | onChange: event =>
111 | setChanges({
112 | ...changes,
113 | description: event.target.value,
114 | }),
115 | name: 'description',
116 | placeholder: '...',
117 | rows: 3,
118 | }),
119 | p(
120 | '.form-input-hint',
121 | t`Para metadatos, resultados de busqueda y dar a conocer tu comunidad.`
122 | ),
123 | ]),
124 | div('.form-group', [
125 | label('.b.form-label', t`Dirección del sitio`),
126 | input('.form-input', {
127 | name: 'url',
128 | type: 'text',
129 | placeholder: t`Ej. https://comunidad.anzu.io`,
130 | required: true,
131 | value: 'url' in changes ? changes.url : site.url,
132 | onChange: event =>
133 | setChanges({
134 | ...changes,
135 | url: event.target.value,
136 | }),
137 | }),
138 | p(
139 | '.form-input-hint.lh-copy',
140 | 'URL absoluta donde vive la instalación de Anzu. Utilizar una dirección no accesible puede provocar no poder acceder al sitio.'
141 | ),
142 | ]),
143 | ]),
144 | div('.bt.b--light-gray.pt2', [
145 | div('.form-group', [
146 | label('.b.form-label', t`Menú de navegación`),
147 | p(
148 | '.form-input-hint',
149 | t`Mostrado en la parte superior del sitio. (- = +)`
150 | ),
151 | div(
152 | nav
153 | .map((link, k) => {
154 | return div(
155 | '.input-group.mb2.fade-in',
156 | { key: `link-${k}` },
157 | [
158 | a(
159 | '.btn.btn-icon.pointer.mr1',
160 | {
161 | onClick: () =>
162 | k > 0 && swapNavLink(k, k - 1),
163 | },
164 | i('.icon-up-outline')
165 | ),
166 | a(
167 | '.btn.btn-icon.pointer.mr1',
168 | {
169 | onClick: () =>
170 | k < nav.length - 1 &&
171 | swapNavLink(k, k + 1),
172 | },
173 | i('.icon-down-outline')
174 | ),
175 | input('.form-input', {
176 | dataset: { id: String(k) },
177 | name: 'name',
178 | type: 'text',
179 | placeholder: '...',
180 | value: link.name,
181 | onChange: event =>
182 | updateNavLink(
183 | k,
184 | 'name',
185 | event.target.value
186 | ),
187 | required: true,
188 | }),
189 | input('.form-input', {
190 | dataset: { id: String(k) },
191 | name: 'href',
192 | type: 'text',
193 | placeholder: '...',
194 | value: link.href,
195 | onChange: event =>
196 | updateNavLink(
197 | k,
198 | 'href',
199 | event.target.value
200 | ),
201 | required: true,
202 | }),
203 | a(
204 | '.btn.btn-icon.pointer.ml1',
205 | {
206 | onClick: () =>
207 | nav.length > 1 &&
208 | deleteNavLink(k),
209 | },
210 | i('.icon-trash')
211 | ),
212 | ]
213 | );
214 | })
215 | .concat([
216 | h('div.tc.mt2', {}, [
217 | h([
218 | span(
219 | '.btn.btn-icon.pointer.mb3',
220 | { onClick: newNavLink },
221 | i('.icon-plus')
222 | ),
223 | ]),
224 | ]),
225 | ])
226 | ),
227 | ]),
228 | ]),
229 | ]);
230 | }
231 |
--------------------------------------------------------------------------------
/src/board/components/login.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import helpers from 'hyperscript-helpers';
3 | import classNames from 'classnames';
4 | import { t } from '../../i18n';
5 |
6 | const tags = helpers(h);
7 | const { div, form, label, i, a, img, input } = tags;
8 |
9 | export function Login({ state, effects }) {
10 | const { auth } = state;
11 | function onSubmit(event) {
12 | event.preventDefault();
13 | if (auth.loading) {
14 | return;
15 | }
16 | effects.performLogin();
17 | }
18 |
19 | const providers = state.site.thirdPartyAuth || [];
20 |
21 | return div(
22 | '.content.fade-in',
23 | { key: 'login', style: { padding: '0 0.4rem 0.5rem' } },
24 | [
25 | providers.includes('fb') &&
26 | div(
27 | '.form-group',
28 | {},
29 | a(
30 | '.btn.btn-primary.db.w-80.btn-facebook.center',
31 | {
32 | href:
33 | Anzu.layer +
34 | 'oauth/facebook?redir=' +
35 | window.location.href,
36 | style: {},
37 | },
38 | [
39 | img({
40 | src: '/dist/images/facebook.svg',
41 | className: 'fl w1',
42 | }),
43 | t`Continuar con Facebook`,
44 | ]
45 | )
46 | ),
47 | providers.includes('fb') &&
48 | div('.form-group.tc', t`ó con tu cuenta anzu`),
49 | form({ onSubmit }, [
50 | div(
51 | '.bg-error.pa2.mb2.f7.fade-in',
52 | { className: classNames({ dn: auth.error === false }) },
53 | t`${auth.error}`
54 | ),
55 | div('.form-group', [
56 | input('.form-input', {
57 | onChange: event =>
58 | effects.auth('email', event.target.value),
59 | value: auth.email,
60 | id: 'email',
61 | type: 'email',
62 | autoComplete: 'username',
63 | placeholder: t`Correo electrónico`,
64 | required: true,
65 | autoFocus: true,
66 | }),
67 | ]),
68 | div('.form-group', [
69 | input('.form-input', {
70 | value: auth.password,
71 | onChange: event =>
72 | effects.auth('password', event.target.value),
73 | id: 'password',
74 | type: 'password',
75 | autoComplete: 'current-password',
76 | placeholder: t`Contraseña`,
77 | required: true,
78 | }),
79 | ]),
80 | div('.form-group', [
81 | label('.form-checkbox', [
82 | input({
83 | type: 'checkbox',
84 | id: 'rememberme',
85 | name: 'rememberme',
86 | onChange: event =>
87 | effects.auth(
88 | 'rememberMe',
89 | event.target.checked
90 | ),
91 | checked: auth.rememberMe,
92 | }),
93 | i('.form-icon'),
94 | t` Recordar mi sesión`,
95 | ]),
96 | ]),
97 | input('.btn.btn-primary.btn-block', {
98 | type: 'submit',
99 | value: t`Iniciar sesión`,
100 | className: classNames({ loading: auth.loading }),
101 | }),
102 | a(
103 | '.db.link.tc.mt2',
104 | {
105 | id: 'forgot',
106 | onClick: () => effects.auth('forgot', true),
107 | },
108 | t`¿Olvidaste tu contraseña?`
109 | ),
110 | ]),
111 | ]
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/src/board/components/modal.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import ReactModal from 'react-modal';
3 |
4 | export function Modal({ children, ...props }) {
5 | return h(
6 | ReactModal,
7 | {
8 | ariaHideApp: false,
9 | style: {
10 | overlay: {
11 | zIndex: 301,
12 | backgroundColor: 'rgba(0, 0, 0, 0.30)',
13 | },
14 | },
15 | ...props,
16 | },
17 | children
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/board/components/quickstart.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import RichTextEditor from 'react-rte';
3 | import { MemoizedMarkdown } from '../utils';
4 | import { useState } from 'react';
5 | import helpers from 'hyperscript-helpers';
6 | import { adminTools } from '../../acl';
7 | import { t } from '../../i18n';
8 | import { QuickstartLink } from './quickstartLink';
9 |
10 | const { section, h1, div, h2, a, span, article } = helpers(h);
11 | const { form, label, input, button } = helpers(h);
12 |
13 | const DEFAULT_LINKS = [
14 | {
15 | href: '/',
16 | name: t`Código de conducta`,
17 | description: t`Si gustas de contribuir te compartimos los lineamientos que está comunidad sigue por el bien de todos.`,
18 | },
19 | {
20 | href: '/',
21 | name: t`Preguntas frecuentes`,
22 | description: t`Antes de preguntar algo te pedimos consultar está sección para saber si alguien más ya ha resuelto esa duda.`,
23 | },
24 | {
25 | href: '/',
26 | name: t`Desarrollo libre`,
27 | description: t`Anzu es una plataforma de código abierto escrita por apasionados del software! Te invitamos a conocer nuestra misión y unirte.`,
28 | },
29 | ];
30 |
31 | export function Quickstart({ state, effects }) {
32 | const { user } = state.auth;
33 | const { site } = state;
34 | const quickstart = site.quickstart || {};
35 | const links = quickstart.links || DEFAULT_LINKS;
36 | const [title, setTitle] = useState(quickstart.headline);
37 | const [content, setContent] = useState(() =>
38 | RichTextEditor.createValueFromString(
39 | quickstart.description || '',
40 | 'markdown'
41 | )
42 | );
43 | const [updating, setUpdating] = useState(false);
44 |
45 | function onUpdate(changes = {}) {
46 | const updated = {
47 | ...quickstart,
48 | ...changes,
49 | };
50 | effects.updateQuickstart(updated).then(() => setUpdating(false));
51 | }
52 |
53 | function onSubmit(event) {
54 | event.preventDefault();
55 | const markdown = content.toString('markdown');
56 | onUpdate({
57 | headline: title,
58 | description: markdown,
59 | });
60 | }
61 |
62 | return div('.flex-auto', [
63 | section('.current-article', [
64 | article([
65 | div('.flex.tile-quickstart.items-center', [
66 | div('.flex-auto', [
67 | updating !== 'headline' &&
68 | h1(
69 | quickstart.headline ||
70 | 'Bienvenido a la comunidad de Anzu.'
71 | ),
72 | updating === 'headline' &&
73 | form('.pv3', { onSubmit }, [
74 | div('.form-group.pb2', [
75 | label(
76 | '.b.form-label',
77 | t`Título o saludo de Bienvenida`
78 | ),
79 | input('.form-input', {
80 | name: 'title',
81 | maxlenght: '40',
82 | type: 'text',
83 | value: title,
84 | placeholder: t`Escribe un saludo de bienvenida o un título`,
85 | onChange: event =>
86 | setTitle(event.target.value),
87 | }),
88 | ]),
89 | div('.pv2', [
90 | button(
91 | '.btn.btn-primary.input-group-btn',
92 | {
93 | type: 'submit',
94 | },
95 | t`Guardar cambios`
96 | ),
97 | button(
98 | '.btn.input-group-btn',
99 | {
100 | type: 'cancel',
101 | onClick: () => setUpdating(false),
102 | },
103 | t`Cancelar`
104 | ),
105 | ]),
106 | ]),
107 | ]),
108 | adminTools({ user }) &&
109 | updating !== 'headline' &&
110 | div('.tile-actions-q', [
111 | a(
112 | '.pointer.post-action',
113 | { onClick: () => setUpdating('headline') },
114 | [span('.dib.icon-edit')]
115 | ),
116 | ]),
117 | ]),
118 | div('.flex.tile-quickstart', [
119 | div('.flex-auto', [
120 | updating !== 'description' &&
121 | h(MemoizedMarkdown, {
122 | content:
123 | quickstart.description ||
124 | 'Únete a la conversación y aporta ideas para el desarrollo de Anzu, una poderosa plataforma de foros y comunidades enfocada en la discusión e interacción entre usuarios en tiempo real.',
125 | }),
126 | updating === 'description' &&
127 | form('.pv3', { onSubmit }, [
128 | div('.form-group.pb2', [
129 | label('.b.form-label', t`Descripción`),
130 | h(RichTextEditor, {
131 | value: content,
132 | onChange: setContent,
133 | placeholder: t`Escribe aquí la descripción de tu sitio.`,
134 | }),
135 | ]),
136 | div('.pv2', [
137 | button(
138 | '.btn.btn-primary.input-group-btn',
139 | {
140 | type: 'submit',
141 | },
142 | t`Guardar cambios`
143 | ),
144 | button(
145 | '.btn.input-group-btn',
146 | {
147 | type: 'cancel',
148 | onClick: () => setUpdating(false),
149 | },
150 | t`Cancelar`
151 | ),
152 | ]),
153 | ]),
154 | ]),
155 | adminTools({ user }) &&
156 | updating !== 'description' &&
157 | div('.tile-actions-q', [
158 | a(
159 | '.pointer.post-action',
160 | { onClick: () => setUpdating('description') },
161 | [span('.dib.icon-edit')]
162 | ),
163 | ]),
164 | ]),
165 | div('.separator'),
166 | h2([t`Si eres nuevo por aquí`]),
167 | div(
168 | '.quick-guide',
169 | links.map((link, index) =>
170 | h(QuickstartLink, {
171 | link,
172 | state,
173 | effects,
174 | onUpdate: link => {
175 | onUpdate({
176 | links: links.map((one, k) =>
177 | k === index ? link : one
178 | ),
179 | });
180 | },
181 | })
182 | )
183 | ),
184 | ]),
185 | ]),
186 | ]);
187 | }
188 |
--------------------------------------------------------------------------------
/src/board/components/quickstartLink.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import { useState } from 'react';
3 | import helpers from 'hyperscript-helpers';
4 | import { adminTools } from '../../acl';
5 | import { t } from '../../i18n';
6 |
7 | const { p, div, a, span, h3 } = helpers(h);
8 | const { form, label, input, button, textarea } = helpers(h);
9 |
10 | export function QuickstartLink({ link, onUpdate, state }) {
11 | const { user } = state.auth;
12 | const [updating, setUpdating] = useState(false);
13 | const [href, setHref] = useState(link.href);
14 | const [name, setName] = useState(link.name);
15 | const [description, setDescription] = useState(link.description);
16 |
17 | function onSubmit(event) {
18 | event.preventDefault();
19 | onUpdate({
20 | name,
21 | description,
22 | href,
23 | });
24 | return setUpdating(false);
25 | }
26 | return div('.tile-quickstart.di', [
27 | h3('.flex', {}, [
28 | updating === false &&
29 | a('.pointer.flex-auto', { href: link.href }, [
30 | link.name + ' ',
31 | span('.icon.icon-arrow-right'),
32 | ]),
33 | updating === true &&
34 | form('.pv3', { onSubmit }, [
35 | div('.form-group.flex-auto', [
36 | label('.b.form-label', t`Nombre de la sección`),
37 | input('.form-input', {
38 | name: 'link-name',
39 | type: 'text',
40 | value: name,
41 | placeholder: t`Escribe un saludo de bienvenida o un título`,
42 | onChange: event => setName(event.target.value),
43 | }),
44 | label('.b.form-label', t`Vínculo`),
45 | input('.form-input', {
46 | name: 'link-href',
47 | type: 'text',
48 | value: href,
49 | placeholder: t`Escribe un saludo de bienvenida o un título`,
50 | onChange: event => setHref(event.target.value),
51 | }),
52 | label('.b.form-label', t`Descripción`),
53 | textarea('.form-input', {
54 | name: 'link-des',
55 | type: 'text',
56 | value: description,
57 | placeholder: t`Escribe un saludo de bienvenida o un título`,
58 | onChange: event =>
59 | setDescription(event.target.value),
60 | rows: 4,
61 | }),
62 | ]),
63 | div('.pv2', [
64 | button(
65 | '.btn.btn-primary.input-group-btn',
66 | {
67 | type: 'submit',
68 | },
69 | t`Guardar cambios`
70 | ),
71 | button(
72 | '.btn.input-group-btn',
73 | {
74 | type: 'cancel',
75 | onClick: () => setUpdating(false),
76 | },
77 | t`Cancelar`
78 | ),
79 | ]),
80 | ]),
81 | adminTools({ user }) &&
82 | updating === false &&
83 | div('.tile-actions-q', [
84 | a(
85 | '.pointer.post-action',
86 | { onClick: () => setUpdating(true) },
87 | [span('.dib.icon-edit')]
88 | ),
89 | ]),
90 | ]),
91 | updating === false && p(link.description),
92 | ]);
93 | }
94 |
--------------------------------------------------------------------------------
/src/board/components/reader.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import h from 'react-hyperscript';
3 | import withState from '../fractals/board';
4 | import { injectState } from 'freactal/lib/inject';
5 | import { Feed } from './feed';
6 | import { Post } from './post';
7 | import observe from 'callbag-observe';
8 |
9 | class Reader extends React.Component {
10 | componentDidMount() {
11 | const { channels } = this.props.state;
12 |
13 | // Subscribe to some events that trigger effects.
14 | observe(event => this.onFeedNsEvent(event))(channels.feed);
15 | }
16 | onFeedNsEvent(data) {
17 | const params = data.p || {};
18 | const { fire } = params;
19 | if (!fire) {
20 | return;
21 | }
22 | this.props.effects.onFeedEvent(params);
23 | }
24 | componentDidUpdate(prev) {
25 | const { location, match, effects, state } = this.props;
26 | const params = new window.URLSearchParams(location.search);
27 | const search = params.get('search') || '';
28 | if (search !== state.feed.search) {
29 | effects.search(search);
30 | return;
31 | }
32 | if (match.path === '/' && prev.match.path !== '/') {
33 | effects.fetchPost(false).then(() => effects.fetchFeed());
34 | }
35 | if (match.path === '/p/:slug/:id') {
36 | const postId = match.params.id || false;
37 | const prevId = prev.match.params.id || false;
38 | if (postId !== prevId && postId !== state.post.id) {
39 | effects.fetchPost(postId);
40 | }
41 | }
42 | if (match.path === '/c/:slug') {
43 | const id = match.params.slug || false;
44 | const prevId = prev.match.params.slug || false;
45 | if (id !== prevId && id !== state.feed.category) {
46 | effects.fetchFeed(id);
47 | }
48 | }
49 | }
50 | render() {
51 | const { post } = this.props.state;
52 | return h(
53 | 'main.board.flex.flex-auto',
54 | { className: post.id !== false ? 'post-active' : '' },
55 | [h(Feed, { ...this.props }), h(Post, { ...this.props })]
56 | );
57 | }
58 | }
59 |
60 | export default withState(injectState(Reader));
61 |
--------------------------------------------------------------------------------
/src/board/components/reply.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import RichTextEditor from 'react-rte';
3 | import classNames from 'classnames';
4 | import h from 'react-hyperscript';
5 | import helpers from 'hyperscript-helpers';
6 | import { t } from '../../i18n';
7 | import { ReplyAdvice } from './replyAdvice';
8 | import { AuthorAvatarLink } from './author';
9 | import { useStoredState } from '../../hooks';
10 |
11 | const tags = helpers(h);
12 | const { form, div, button } = tags;
13 |
14 | export function ReplyView(props) {
15 | const { effects, auth, ui, type, id } = props;
16 | const [markdown, setMarkdown] = useStoredState(
17 | `markdown.${type}.${id}`,
18 | ''
19 | );
20 | const [editorState, setEditorState] = useState(
21 | RichTextEditor.createValueFromString(markdown, 'markdown')
22 | );
23 | const [mobileEditorContent, setMobileContent] = useState('');
24 | const { user } = auth;
25 | const nested = props.nested || false;
26 |
27 | async function onSubmit(event) {
28 | event.preventDefault();
29 | const markdown =
30 | mobileEditorContent || editorState.toString('markdown');
31 | if (ui.replying || markdown.length === 0) {
32 | return;
33 | }
34 |
35 | // We now execute the actual side effect and reset our reply state.
36 | await effects.publishReply(markdown, type, id);
37 | setEditorState(RichTextEditor.createEmptyValue());
38 | setMobileContent('');
39 | }
40 |
41 | useEffect(() => {
42 | const content = editorState.toString('markdown');
43 | const sanitized = content.replace(/[^\x20-\x7E]/g, '');
44 | setMarkdown(sanitized);
45 | }, [editorState]);
46 |
47 | return div(
48 | '.comment.reply.flex.fade-in.items-start',
49 | { className: classNames({ pb3: nested == false, pt3: nested }) },
50 | [
51 | h(AuthorAvatarLink, { user }),
52 | form('.pl2-ns.flex-auto.fade-in.reply-form', { onSubmit }, [
53 | div(
54 | '.form-group.dn.db-ns',
55 | {},
56 | h(RichTextEditor, {
57 | value: editorState,
58 | onChange: setEditorState,
59 | placeholder: 'Escribe aquí tu respuesta',
60 | onFocus: () => effects.replyFocus(type, id),
61 | })
62 | ),
63 | div(
64 | '.form-group.db.dn-ns',
65 | {},
66 | h('textarea.form-input', {
67 | value: mobileEditorContent,
68 | onChange: event => setMobileContent(event.target.value),
69 | placeholder: 'Escribe aquí tu respuesta',
70 | onFocus: () => effects.replyFocus(type, id),
71 | rows: 5,
72 | })
73 | ),
74 | h(ReplyAdvice),
75 | div(
76 | '.tr.mt2',
77 | {
78 | className: classNames({
79 | dn:
80 | ui.commenting == false ||
81 | ui.commentingType !== type ||
82 | ui.commentingId != id,
83 | }),
84 | },
85 | [
86 | button(
87 | '.btn.btn-primary.mr2.dn.dib-ns',
88 | {
89 | type: 'submit',
90 | className: classNames({
91 | loading: ui.replying,
92 | }),
93 | },
94 | t`Publicar comentario`
95 | ),
96 | button(
97 | '.btn.btn-primary.mr2.dib.dn-ns',
98 | {
99 | type: 'submit',
100 | className: classNames({
101 | loading: ui.replying,
102 | }),
103 | },
104 | t`Publicar`
105 | ),
106 | button(
107 | '.btn',
108 | {
109 | type: 'reset',
110 | onClick: () => effects.replyFocus(false),
111 | },
112 | t`Cancelar`
113 | ),
114 | ]
115 | ),
116 | ]),
117 | ]
118 | );
119 | }
120 |
--------------------------------------------------------------------------------
/src/board/components/replyAdvice.js:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import h from 'react-hyperscript';
3 | import helpers from 'hyperscript-helpers';
4 | import { t } from '../../i18n';
5 | import { useSessionState } from '../../hooks';
6 |
7 | const tags = helpers(h);
8 | const { div, p, ul, li } = tags;
9 | const { a, i, strong } = tags;
10 |
11 | export function ReplyAdvice(props) {
12 | const [hidden, hideRecommendations] = useSessionState(
13 | 'replyRecommendations',
14 | false
15 | );
16 | return div(
17 | '.toast.toast-warning.context-help',
18 | {
19 | className: classNames({
20 | dn: props.hidden || hidden,
21 | }),
22 | },
23 | [
24 | a('.btn.btn-clear.float-right', {
25 | onClick: () => hideRecommendations(true),
26 | }),
27 | p(t`Gracias por contribuir con tu respuesta!`),
28 | ul([
29 | li([
30 | t`Asegúrate de `,
31 | i(t`responder la publicación principal `),
32 | t`y proporcionar detalles suficientes en tu respuesta.`,
33 | ]),
34 | ]),
35 | p([t`Y trata de `, strong(t`evitar`), ':']),
36 | ul([
37 | li(t`Responder con otra pregunta.`),
38 | li(t`Responder a otras respuestas.`),
39 | li(
40 | t`Responder sólo tu opinión. Argumenta con referencias o tu experiencia personal.`
41 | ),
42 | ]),
43 | p([
44 | t`También puedes consultar nuestras recomendaciones sobre `,
45 | a(t`cómo escribir buenas respuestas.`),
46 | ]),
47 | ]
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/board/components/signup.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import helpers from 'hyperscript-helpers';
3 | import classNames from 'classnames';
4 | import { t } from '../../i18n';
5 |
6 | const tags = helpers(h);
7 | const { div, form, img, a, input } = tags;
8 |
9 | export function Signup({ state, effects }) {
10 | const { auth } = state;
11 | function onSubmit(event) {
12 | event.preventDefault();
13 | if (auth.loading) {
14 | return;
15 | }
16 | effects.performSignup();
17 | }
18 | const providers = state.site.thirdPartyAuth || [];
19 | return div(
20 | '.content.fade-in',
21 | { key: 'signup', style: { padding: '0 0.4rem' } },
22 | [
23 | providers.includes('fb') &&
24 | div(
25 | '.form-group',
26 | {},
27 | a(
28 | '.btn.btn-primary.db.w-80.btn-facebook.center',
29 | {
30 | href:
31 | Anzu.layer +
32 | 'oauth/facebook?redir=' +
33 | window.location.href,
34 | style: {},
35 | },
36 | [
37 | img({
38 | src: '/dist/images/facebook.svg',
39 | className: 'fl w1',
40 | }),
41 | t`Continuar con Facebook`,
42 | ]
43 | )
44 | ),
45 | providers.includes('fb') &&
46 | div('.form-group.tc', t`ó crea una cuenta con tu correo`),
47 | form({ onSubmit }, [
48 | div(
49 | '.bg-error.pa2.mb2.f7.fade-in',
50 | { className: classNames({ dn: auth.error === false }) },
51 | t`${auth.error}`
52 | ),
53 | div('.form-group', [
54 | input('.form-input', {
55 | onChange: event =>
56 | effects.auth('email', event.target.value),
57 | value: auth.email,
58 | id: 'email',
59 | type: 'email',
60 | placeholder: 'Correo electrónico',
61 | required: true,
62 | autoFocus: true,
63 | }),
64 | ]),
65 | div('.form-group', [
66 | input('.form-input', {
67 | onChange: event =>
68 | effects.auth('username', event.target.value),
69 | value: auth.username,
70 | type: 'text',
71 | placeholder: 'Nombre de usuario',
72 | required: true,
73 | }),
74 | ]),
75 | div('.form-group', [
76 | input('.form-input', {
77 | value: auth.password,
78 | onChange: event =>
79 | effects.auth('password', event.target.value),
80 | id: 'password',
81 | type: 'password',
82 | placeholder: 'Contraseña',
83 | required: true,
84 | }),
85 | ]),
86 | input('.btn.btn-primary.btn-block', {
87 | type: 'submit',
88 | value: 'Crear cuenta',
89 | className: classNames({ loading: auth.loading }),
90 | }),
91 | ]),
92 | ]
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/src/board/components/usersModal.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import h from 'react-hyperscript';
3 | import Modal from 'react-modal';
4 | import helpers from 'hyperscript-helpers';
5 | import { t, ago } from '../../i18n';
6 | import { throttle } from 'lodash';
7 | import { Link } from 'react-router-dom';
8 | import { BanWithReason } from './actions';
9 |
10 | const tags = helpers(h);
11 | const { div, img, figure, ul, li, a } = tags;
12 | const { span, h2, small, i, input } = tags;
13 |
14 | const throttledScroll = throttle((bottomReached, effects, { list }) => {
15 | if (bottomReached) {
16 | const last = list.length > 0 ? list[list.length - 1].id : false;
17 | effects.fetchUsers(last);
18 | }
19 | }, 200);
20 |
21 | export function UsersModal({ state, effects, setOpen }) {
22 | const { users } = state;
23 |
24 | // Initial users fetch the first time the modal is mounted.
25 | useEffect(() => {
26 | effects.fetchUsers();
27 | }, []);
28 |
29 | function onScroll(e) {
30 | const bottomReached =
31 | e.target.scrollHeight - e.target.scrollTop - e.target.clientHeight <
32 | 1;
33 | throttledScroll(bottomReached, effects, users);
34 | }
35 |
36 | return h(
37 | Modal,
38 | {
39 | isOpen: true,
40 | onRequestClose: () => setOpen(false),
41 | ariaHideApp: false,
42 | contentLabel: t`Herramientas para Usuarios`,
43 | className: 'users-modal',
44 | style: {
45 | overlay: {
46 | zIndex: 301,
47 | backgroundColor: 'rgba(0, 0, 0, 0.30)',
48 | },
49 | },
50 | },
51 | div('.modal-container.fade-in', { style: { width: '640px' } }, [
52 | div('.pa3.flex.items-center', [
53 | h2('.f5.pa0.flex-auto.w-60.ma0', t`Gestión de usuarios`),
54 | input('.form-input.input-sm.w-40', {
55 | type: 'search',
56 | placeholder: t`Buscar usuario...`,
57 | required: true,
58 | }),
59 | a('.btn-icon.ml3', { onClick: () => setOpen(false) }, [
60 | span('.icon-cancel'),
61 | ]),
62 | ]),
63 | div(
64 | '.ph3.pb3.bt.b--light-gray.overflow-y-auto.pt2',
65 | { onScroll, style: { maxHeight: 600 } },
66 | users.list.map(user => {
67 | return div('.flex.pv2.items-center', { key: user.id }, [
68 | figure(
69 | '.avatar.tc',
70 | {
71 | dataset: {
72 | initial: user.username.substr(0, 2),
73 | },
74 | },
75 | [
76 | user.image &&
77 | img({
78 | src: user.image,
79 | alt: t`Avatar de ${user.username}`,
80 | }),
81 | ]
82 | ),
83 | div('.pl3.lh-title.flex-auto', [
84 | h(
85 | Link,
86 | {
87 | to: `/u/${user.username}/${user.id}`,
88 | onClick: () => {
89 | setOpen(false);
90 | },
91 | className: 'f6',
92 | },
93 | user.username
94 | ),
95 | div(
96 | '.dib.mr2',
97 | {},
98 | small(
99 | '.bg-light-gray.br1.text-gray.ml2',
100 | { style: { padding: '2px 5px' } },
101 | [
102 | i('.icon-crown'),
103 | span(
104 | '.b',
105 | ' ' + String(user.gaming.swords)
106 | ),
107 | ]
108 | )
109 | ),
110 | span(
111 | '.ago.db.f7.text-gray.mt2',
112 | t`Miembro desde hace` +
113 | ' ' +
114 | ago(user.created_at)
115 | ),
116 | ]),
117 | div('.w-30.tr.pr3.f7', [
118 | user.validated &&
119 | span(
120 | '.label.label-sm.label-success',
121 | t`Correo validado`
122 | ),
123 | !user.validated &&
124 | span(
125 | '.label.label-sm.label-error',
126 | t`Correo sin validar`
127 | ),
128 | ]),
129 | div([
130 | div('.dib.v-mid.dropdown.dropdown-right', [
131 | a(
132 | {
133 | className:
134 | 'dib v-mid btn-icon dropdown-toggle',
135 | tabIndex: 0,
136 | },
137 | h('i.icon-down-open.f7')
138 | ),
139 | ul('.menu', { style: { width: '200px' } }, [
140 | li(
141 | '.menu-item',
142 | {},
143 | h(
144 | BanWithReason,
145 | {
146 | title: t`¿Por qué quieres banear este usuario?`,
147 | user,
148 | onSend: form =>
149 | effects.requestUserBan({
150 | ...form,
151 | user_id: user.id,
152 | }),
153 | },
154 | [
155 | i('.mr1.icon-edit'),
156 | t`Banear cuenta`,
157 | ]
158 | )
159 | ),
160 | ]),
161 | ]),
162 | ]),
163 | ]);
164 | })
165 | ),
166 | ])
167 | );
168 | }
169 |
--------------------------------------------------------------------------------
/src/board/containers/home.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import h from 'react-hyperscript';
3 | import observe from 'callbag-observe';
4 | import { Navbar } from '../components/navbar';
5 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
6 | import Reader from '../components/reader';
7 | import Profile from '../components/profile';
8 | import Publisher from '../components/publisher';
9 | import withState, { AuthContext } from '../fractals/auth';
10 | import { injectState } from 'freactal/lib/inject';
11 | import { Account } from '../components/account';
12 | import { ToastContainer } from 'react-toastify';
13 | import Chat from './chat';
14 | import { audio } from '../utils';
15 |
16 | // Default export has the injected auth state.
17 | export default withState(injectState(Home));
18 |
19 | // Inject state into Reader. (share some parent state with initialize).
20 | const ReaderWithParentState = injectState(Reader);
21 | const PublisherWithState = injectState(Publisher);
22 |
23 | export function Home({ state, effects }) {
24 | useEffect(() => {
25 | // Subscribe to some events that trigger effects.
26 | observe(data => onGlobalNsEvent({ data, effects }))(
27 | state.channels.global
28 | );
29 | }, []);
30 | return h(
31 | AuthContext.Provider,
32 | { value: state },
33 | h(
34 | Router,
35 | {},
36 | h(React.Fragment, [
37 | h('div.flex.flex-column.flex-auto', [
38 | h(Navbar, { state, effects }),
39 | h(Switch, [
40 | h(Route, {
41 | exact: true,
42 | path: '/',
43 | component: ReaderWithParentState,
44 | }),
45 | h(Route, {
46 | path: '/chat/:chan',
47 | component: Chat,
48 | }),
49 | h(Route, {
50 | path: '/chat',
51 | component: Chat,
52 | }),
53 | h(Route, {
54 | path: '/u/:slug/:id',
55 | component: Profile,
56 | }),
57 | h(Route, {
58 | path: '/c/:slug',
59 | component: ReaderWithParentState,
60 | }),
61 | h(Route, {
62 | path: '/p/:slug/:id',
63 | component: ReaderWithParentState,
64 | }),
65 | h(Route, {
66 | path: '/publicar',
67 | component: PublisherWithState,
68 | }),
69 | ]),
70 | ]),
71 | h(Account, { state, effects }),
72 | h(ToastContainer),
73 | ])
74 | )
75 | );
76 | }
77 |
78 | function onGlobalNsEvent({ data, effects }) {
79 | const type = data.event || data.action;
80 | switch (type) {
81 | case 'config':
82 | return effects.updateSite(data.params || {});
83 | case 'notification':
84 | window.setTimeout(audio.play, 200);
85 | return effects.notifications('count', data.p.count || 0);
86 | case 'gaming':
87 | return effects.updateGaming(data.p);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/board/errors.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import h from 'react-hyperscript';
3 |
4 | export class ErrorBoundary extends React.Component {
5 | constructor(props) {
6 | super(props);
7 | this.state = { hasError: false };
8 | }
9 |
10 | static getDerivedStateFromError() {
11 | // Update state so the next render will show the fallback UI.
12 | return { hasError: true };
13 | }
14 |
15 | render() {
16 | if (this.state.hasError) {
17 | // You can render any custom fallback UI
18 | return h(
19 | 'div.toast.toast-error.mb2.lh-copy',
20 | 'Ocurrió un error al intentar renderizar este contenido. Por seguridad hemos evitado su visualización y revisaremos la incidencia en breve.'
21 | );
22 | }
23 |
24 | return this.props.children;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/board/fractals/profile.js:
--------------------------------------------------------------------------------
1 | import { provideState } from 'freactal';
2 | import { request } from '../../utils';
3 | import { kvReducer } from '../utils';
4 |
5 | function initialState() {
6 | return {
7 | profile: {
8 | error: false,
9 | loading: false,
10 | id: false,
11 | data: {},
12 | posts: {},
13 | },
14 | comments: {
15 | loading: false,
16 | count: 0,
17 | list: [],
18 | },
19 | posts: {
20 | loading: false,
21 | count: 0,
22 | list: [],
23 | },
24 | };
25 | }
26 |
27 | function initProfile(_, id = false) {
28 | return state => ({
29 | ...state,
30 | profile: {
31 | ...state.profile,
32 | data: {},
33 | loading: id && true,
34 | id,
35 | },
36 | comments: {
37 | ...state.comments,
38 | loading: true,
39 | },
40 | posts: {
41 | ...state.posts,
42 | loading: true,
43 | },
44 | });
45 | }
46 |
47 | function jsonReq(req) {
48 | return req.then(response => response.json());
49 | }
50 |
51 | function syncProfile(effects, id) {
52 | return effects
53 | .fetchProfile(id)
54 | .then(() =>
55 | Promise.all([
56 | jsonReq(request(`users/${id}/comments`)),
57 | jsonReq(
58 | request(`feed`, {
59 | query: { user_id: id, offset: 0, limit: 10 },
60 | })
61 | ),
62 | ])
63 | )
64 | .then(([comments, posts]) => state => ({
65 | ...state,
66 | posts: {
67 | ...state.posts,
68 | count: posts.count,
69 | list: posts.feed,
70 | loading: false,
71 | },
72 | comments: {
73 | ...state.comments,
74 | count: comments.count,
75 | list: comments.activity,
76 | loading: false,
77 | },
78 | }));
79 | }
80 |
81 | function updateAvatar(effects, form) {
82 | return effects.postNewAvatar(form).then(authState => state => ({
83 | ...state,
84 | profile: {
85 | ...state.profile,
86 | data: {
87 | ...state.profile.data,
88 | image: authState.auth.user.image,
89 | },
90 | },
91 | }));
92 | }
93 |
94 | function updateCurrentProfile(effects, form) {
95 | return effects.updateProfile(form).then(authState => state => ({
96 | ...state,
97 | profile: {
98 | ...state.profile,
99 | data: {
100 | ...state.profile.data,
101 | ...authState.auth.user,
102 | },
103 | },
104 | }));
105 | }
106 |
107 | async function fetchProfile(effects, id) {
108 | try {
109 | await effects.initProfile(id);
110 |
111 | // Stop side effects if false userId was provided.
112 | if (id === false) {
113 | return state => state;
114 | }
115 |
116 | // Fetch remote post data.
117 | const remote = await request(`users/${id}`);
118 | if (remote.status !== 200) {
119 | throw remote.status;
120 | }
121 | const data = await remote.json();
122 | return state => ({
123 | ...state,
124 | profile: {
125 | ...state.profile,
126 | loading: false,
127 | error: false,
128 | id,
129 | data,
130 | },
131 | });
132 | } catch (error) {
133 | return state => ({
134 | ...state,
135 | post: {
136 | ...state.post,
137 | loading: false,
138 | data: {},
139 | id,
140 | error,
141 | },
142 | });
143 | }
144 | }
145 |
146 | export default provideState({
147 | effects: {
148 | initProfile,
149 | fetchProfile,
150 | syncProfile,
151 | updateAvatar,
152 | updateCurrentProfile,
153 | profile: kvReducer('profile'),
154 | },
155 | computed: {},
156 | initialState,
157 | });
158 |
--------------------------------------------------------------------------------
/src/board/utils.js:
--------------------------------------------------------------------------------
1 | import h from 'react-hyperscript';
2 | import Markdown from 'react-markdown';
3 | import emoji from 'emoji-dictionary';
4 | import { memo } from 'react';
5 | import { Link } from 'react-router-dom';
6 |
7 | function emojiSupport(props) {
8 | return props.children.replace(
9 | /:\+1:|:-1:|:[\w-]+:/g,
10 | name => emoji.getUnicode(name) || name
11 | );
12 | }
13 |
14 | export function getLocalValue(key) {
15 | try {
16 | return window.localStorage.getItem(key);
17 | } catch (error) {
18 | console.error(error);
19 | }
20 | return '';
21 | }
22 |
23 | function linkSupport(props) {
24 | return props.href.match(/^(https?:)?\/\//)
25 | ? h(
26 | 'a',
27 | {
28 | href: props.href,
29 | target: '_blank',
30 | rel: 'noopener noreferrer',
31 | },
32 | props.children
33 | )
34 | : h(Link, { to: props.href }, props.children);
35 | }
36 |
37 | function linkParser(onImageLoad) {
38 | return props => {
39 | const supported = ['gif', 'jpeg', 'jpg', 'png'];
40 | if (supported.filter(ext => props.href.endsWith(ext)).length > 0) {
41 | return h('img.chat', {
42 | src: props.href,
43 | onLoad: onImageLoad,
44 | style: { maxWidth: 300 },
45 | });
46 | }
47 | return props.href.match(/^(https?:)?\/\//)
48 | ? h(
49 | 'a',
50 | {
51 | href: props.href,
52 | target: 'blank',
53 | rel: 'noopener noreferrer',
54 | },
55 | props.children
56 | )
57 | : h(Link, { to: props.href }, props.children);
58 | };
59 | }
60 |
61 | export const MemoizedBasicMarkdown = memo(({ content, onImageLoad }) => {
62 | return h(Markdown, {
63 | source: content,
64 | renderers: { text: emojiSupport, link: linkParser(onImageLoad) },
65 | disallowedTypes: ['heading'],
66 | escapeHtml: true,
67 | });
68 | });
69 |
70 | export const MemoizedMarkdown = memo(({ content }) => {
71 | return h(Markdown, {
72 | source: content,
73 | renderers: { text: emojiSupport, link: linkSupport },
74 | escapeHtml: false,
75 | });
76 | });
77 |
78 | export function kvReducer(obj) {
79 | return (_, key, value = false) => {
80 | if (typeof key === 'object') {
81 | return state => ({
82 | ...state,
83 | [obj]: {
84 | ...state[obj],
85 | ...key,
86 | },
87 | });
88 | }
89 | return state => ({
90 | ...state,
91 | [obj]: {
92 | ...state[obj],
93 | [key]: value,
94 | },
95 | });
96 | };
97 | }
98 |
99 | export function jsonReq(req) {
100 | return req
101 | .then(response => response.json())
102 | .then(response => {
103 | const status = response.status || 'okay';
104 | const message =
105 | response.message || 'Invalid response, check network requests.';
106 | if (status !== 'okay') {
107 | throw message;
108 | }
109 | return response;
110 | });
111 | }
112 |
113 | function socketEvent(event, params) {
114 | return JSON.stringify({
115 | event,
116 | params,
117 | });
118 | }
119 |
120 | export function channelToObs(socket, name = false) {
121 | return {
122 | subscribe: observer => {
123 | function listen() {
124 | // When is not the global namespace we need to explicitly tell
125 | // the remote to join us into that channel.
126 | if (name) {
127 | socket.send(socketEvent('listen', { chan: name }));
128 | }
129 | const channel = name ? socket.channel(name) : socket;
130 | channel.onMessage(m => {
131 | const msg = JSON.parse(m);
132 | observer.next(msg);
133 | });
134 | }
135 | if (socket.state() === 'connected') {
136 | listen();
137 | } else {
138 | socket.on('connected', listen);
139 | }
140 | return () => {
141 | socket.send(socketEvent('unlisten', { chan: name }));
142 | observer.complete(name);
143 | };
144 | },
145 | };
146 | }
147 |
148 | export function glueEvent(event, params) {
149 | return JSON.stringify({ event, params });
150 | }
151 |
152 | export const audio = new window.Audio('/assets/sounds/notification.mp3');
153 | export const chatAudio = new window.Audio('/assets/sounds/chat.mp3');
154 |
--------------------------------------------------------------------------------
/src/drivers/tribute.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import React, { Component } from 'react';
3 | import ReactDOM from 'react-dom';
4 | import TributeJS from 'tributejs';
5 | import PropTypes from 'prop-types';
6 | import h from 'react-hyperscript';
7 |
8 | const { arrayOf, func, node, object, oneOfType, shape } = PropTypes;
9 |
10 | export default class Tribute extends Component {
11 | static propTypes = {
12 | customRef: func,
13 | children: node,
14 | onChange: func,
15 | options: shape({
16 | collections: arrayOf(arrayOf(object)),
17 | values: arrayOf(object),
18 | lookup: func,
19 | menuContainer: oneOfType([object, func]),
20 | }).isRequired,
21 | };
22 |
23 | static defaultProps = {
24 | onChange: () => {},
25 | };
26 |
27 | children = [];
28 | listeners = [];
29 | tribute = null;
30 |
31 | componentDidMount() {
32 | this.bindToChildren();
33 | }
34 |
35 | bindToChildren = () => {
36 | const { customRef, options } = this.props;
37 |
38 | const realOptions = {
39 | ...options,
40 | };
41 |
42 | if (typeof options.menuContainer === 'function') {
43 | const node = options.menuContainer();
44 |
45 | if (node instanceof Component) {
46 | realOptions.menuContainer = ReactDOM.findDOMNode(node);
47 | } else {
48 | realOptions.menuContainer = node;
49 | }
50 | }
51 |
52 | (customRef ? [customRef()] : this.children).forEach(child => {
53 | const node =
54 | child instanceof Component
55 | ? ReactDOM.findDOMNode(child)
56 | : child;
57 |
58 | const t = new TributeJS({
59 | ...realOptions,
60 | });
61 |
62 | t.attach(node);
63 |
64 | this.tribute = t;
65 |
66 | const listener = this.handleTributeReplaced.bind(this);
67 | node.addEventListener('tribute-replaced', listener);
68 | this.listeners.push(listener);
69 | });
70 | };
71 |
72 | handleTributeReplaced = event => {
73 | this.props.onChange(event);
74 | };
75 |
76 | render() {
77 | const {
78 | children,
79 | options: _,
80 | customRef: __,
81 | onChange: ___,
82 | ...props
83 | } = this.props;
84 |
85 | return h(
86 | 'div',
87 | { ...props },
88 | React.Children.map(children, (element, index) => {
89 | return React.cloneElement(element, {
90 | ref: ref => {
91 | this.children[index] = ref;
92 | },
93 | });
94 | })
95 | );
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/hooks/index.js:
--------------------------------------------------------------------------------
1 | export { default as useStoredState } from './useStoredState';
2 | export { default as useSessionState } from './useSessionState';
3 | export { default as useWindowVisibility } from './useWindowVisibility';
4 | export { default as useTitleNotification } from './useTitleNotification';
5 |
--------------------------------------------------------------------------------
/src/hooks/useSessionState.js:
--------------------------------------------------------------------------------
1 | import useStoredState from './useStoredState';
2 |
3 | /**
4 | * A temporal sessionStorage persisted state hook.
5 | *
6 | * @param {String} name of the state container.
7 | * @param {Any} any default value to be stored.
8 | */
9 | export default function useSessionState(name, any = null) {
10 | return useStoredState(name, any, 'sessionStorage');
11 | }
12 |
--------------------------------------------------------------------------------
/src/hooks/useStoredState.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | /**
4 | * A temporal persisted state hook. (local/session storage).
5 | * Most likely this can be also stored in apollo cache. But let's have this a simplified solution.
6 | *
7 | * @param {String} name of the state container.
8 | * @param {Any} any default value to be stored.
9 | */
10 | export default function useStoredState(
11 | name,
12 | any = null,
13 | storage = 'localStorage'
14 | ) {
15 | const key = `anzu.${name}`;
16 | const stored = window[storage].getItem(key) || null;
17 | const parsed = stored !== null ? JSON.parse(stored).value : any;
18 | const [value, setValue] = useState(parsed);
19 |
20 | function _setValue(value) {
21 | window[storage].setItem(key, JSON.stringify({ value }));
22 | return setValue(value);
23 | }
24 |
25 | // Return stored values.
26 | return [value, _setValue];
27 | }
28 |
--------------------------------------------------------------------------------
/src/hooks/useTitleNotification.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import useWindowVisibility from './useWindowVisibility';
3 | import { audio, chatAudio } from '../board/utils';
4 |
5 | const sounds = {
6 | default: audio,
7 | chat: chatAudio,
8 | };
9 |
10 | export default function useTitleNotification({ type = 'default' }) {
11 | const [isWindowActive, isWindowActiveRef] = useWindowVisibility();
12 |
13 | // Reset notification title as needed.
14 | useEffect(() => {
15 | if (isWindowActive && document.title.substr(0, 3) === '(*)') {
16 | document.title = document.title.substr(4);
17 | }
18 | }, [isWindowActive]);
19 |
20 | function pingNotification() {
21 | if (!isWindowActiveRef.current) {
22 | document.title =
23 | document.title.substr(0, 3) === '(*)'
24 | ? document.title
25 | : `(*) ${document.title}`;
26 | sounds[type].play();
27 | }
28 | }
29 |
30 | return [isWindowActive, { pingNotification }];
31 | }
32 |
--------------------------------------------------------------------------------
/src/hooks/useWindowVisibility.js:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect } from 'react';
2 |
3 | export default function useWindowVisibility() {
4 | const [windowIsActive, setWindowIsActive] = useState(true);
5 | const activeRef = useRef(true);
6 |
7 | function handleActivity(forcedFlag) {
8 | if (typeof forcedFlag === 'boolean') {
9 | return forcedFlag
10 | ? setWindowIsActive(true)
11 | : setWindowIsActive(false);
12 | }
13 |
14 | return document.hidden
15 | ? setWindowIsActive(false)
16 | : setWindowIsActive(true);
17 | }
18 |
19 | useEffect(() => {
20 | document.addEventListener('visibilitychange', handleActivity);
21 | document.addEventListener('blur', () => handleActivity(false));
22 | window.addEventListener('blur', () => handleActivity(false));
23 | window.addEventListener('focus', () => handleActivity(true));
24 | document.addEventListener('focus', () => handleActivity(true));
25 |
26 | return () => {
27 | window.removeEventListener('blur', handleActivity);
28 | document.removeEventListener('blur', handleActivity);
29 | window.removeEventListener('focus', handleActivity);
30 | document.removeEventListener('focus', handleActivity);
31 | document.removeEventListener('visibilitychange', handleActivity);
32 | };
33 | }, []);
34 |
35 | useEffect(() => {
36 | activeRef.current = windowIsActive;
37 | }, [windowIsActive]);
38 |
39 | return [windowIsActive, activeRef];
40 | }
41 |
--------------------------------------------------------------------------------
/src/mount.js:
--------------------------------------------------------------------------------
1 | import 'babel-polyfill';
2 | import { render } from 'react-dom';
3 | import h from 'react-hyperscript';
4 | import Home from './board/containers/home';
5 |
6 | function anzu(elm, props = {}) {
7 | return render(h(Home, { ...props }), elm);
8 | }
9 |
10 | document.addEventListener('DOMContentLoaded', function () {
11 | anzu(document.getElementById('react-anzu'));
12 | });
13 |
--------------------------------------------------------------------------------
/src/requests.js:
--------------------------------------------------------------------------------
1 | import { jsonReq } from './board/utils';
2 | import { request } from './utils';
3 |
4 | export function requestFlags() {
5 | return jsonReq(request('reasons/flag')).then(res => res.reasons);
6 | }
7 |
8 | export function requestBans() {
9 | return jsonReq(request('reasons/ban')).then(res => res.reasons);
10 | }
11 |
12 | export function requestMentionable(str) {
13 | return jsonReq(request(`search/users/${str}`)).then(res => res.list);
14 | }
15 |
--------------------------------------------------------------------------------
/src/streams.js:
--------------------------------------------------------------------------------
1 | import {
2 | pipe,
3 | filter,
4 | merge,
5 | fromObs,
6 | map,
7 | scan,
8 | fromPromise,
9 | } from 'callbag-basics';
10 | import { channelToObs } from './board/utils';
11 | import { debounce } from 'callbag-debounce';
12 | import subscribe from 'callbag-subscribe';
13 |
14 | export function streamChatChannel({ realtime, chan }, next) {
15 | const types = ['listen:ready', 'boot', 'message', 'log', 'delete', 'star'];
16 | // Transform this reactive structure into what we finally need (list of messages)
17 | return pipe(
18 | // Stream of chat's channel messages from glue socket.
19 | merge(
20 | fromObs(channelToObs(realtime, 'chat:' + chan)),
21 | fromObs(channelToObs(realtime)),
22 | fromPromise(Promise.resolve({ event: 'boot' }))
23 | ),
24 | // Flattening of object params & type
25 | map(msg => ({ ...msg.params, type: msg.event })),
26 | // Filtering known messages, just in case.
27 | filter(msg => types.includes(msg.type)),
28 | // Merging into single list
29 | scan(
30 | (prev, msg) => {
31 | switch (msg.type) {
32 | case 'delete':
33 | return {
34 | ...prev,
35 | list: prev.list.filter(m => m.id !== msg.id),
36 | };
37 | case 'star':
38 | return {
39 | ...prev,
40 | starred: [msg].concat(prev.starred),
41 | };
42 | case 'log':
43 | case 'message':
44 | return {
45 | ...prev,
46 | list: prev.list.concat(msg),
47 | };
48 | case 'boot':
49 | return {
50 | ...prev,
51 | starred: [],
52 | list: [],
53 | };
54 | case 'listen:ready':
55 | if (chan === msg.chan) {
56 | return {
57 | ...prev,
58 | starred: [],
59 | list: [],
60 | };
61 | }
62 | return prev;
63 | }
64 | },
65 | { list: [], starred: [] }
66 | ),
67 | debounce(60),
68 | subscribe({ next })
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/themes/autumn/_common.scss:
--------------------------------------------------------------------------------
1 | body, html {
2 | height: 100%;
3 | }
4 |
5 | body {
6 | background: $light-shade;
7 | }
8 |
9 | p.md p:last-child {
10 | margin-bottom: 0 !important;
11 | }
12 |
13 | .btn.btn-primary.btn-facebook {
14 | background: #4267b2 !important;
15 | border-color: #4267b2 !important;
16 | border-radius: 4px;
17 | color: #fff !important;
18 | }
19 |
20 | .step .step-item a {
21 | color: #777777;
22 | }
23 |
24 | .toast.toast-error {
25 | background: rgba(232, 0, 30, 0.9);
26 | border-color: #c61d31;
27 | }
28 |
29 | .toast.toast-primary {
30 | background: $primary-color;
31 | border-color: $primary-color;
32 | }
33 |
34 | .toast.toast-warning {
35 | background: #FFF9C2;
36 | border-color: #e3dfb3;
37 | color: #22292F;
38 |
39 | a {
40 | color: #22292F;
41 | }
42 | }
43 |
44 | .avatar {
45 |
46 | &.avatar-xl {
47 | width: 6.4rem;
48 | height: 6.4rem;
49 | }
50 | }
51 |
52 | .label.label-sm {
53 | padding-left: .25rem;
54 | padding-right: .25rem;
55 | font-size: 13px;
56 | }
57 |
58 | .dropdown .menu {
59 | min-width: 210px;
60 | }
61 |
62 | .popover .popover-container {
63 | width: 210px;
64 | }
65 |
66 | .card .card-body {
67 | padding: .25rem;
68 | &:last-child {
69 | padding: .25rem;
70 | }
71 | }
72 |
73 | .btn-icon {
74 | border: 1px solid $gray-color;
75 | border-radius: .125rem;
76 | cursor: pointer;
77 |
78 | &.active {
79 | background: $primary-color;
80 | //border-color: color($primary-color blackness(50%));
81 | text-decoration: none;
82 | color: #FFF;
83 | color: rgba(255, 255, 255, 0.9);
84 |
85 | &:hover {
86 | color: #FFF;
87 | //border-color: color($primary-color blackness(60%));
88 | }
89 | }
90 |
91 | &:hover {
92 | border-color: $primary-color;
93 | text-decoration: none;
94 | }
95 | }
96 |
97 | .modal.active .modal-container, .modal:target .modal-container {
98 | padding: 0;
99 | }
100 |
101 | .form-input:not(:placeholder-shown):invalid {
102 | border-color: #e80060;
103 | }
104 |
105 | .empty {
106 | background: none;
107 | }
108 |
109 | .gray {
110 | color: $gray-color-dark !important;
111 | }
112 |
113 | .b--light-gray {
114 | border-color: #cfd6de !important;
115 | }
116 |
117 | .fade-in {animation:fade-in 0.5s;}
118 |
119 | .form-select.select-sm {
120 | color: $dark-color;
121 | background-color: $light-color !important;
122 | margin-right: 0.5rem;
123 | }
124 |
125 | @keyframes fade-in {
126 | from {opacity:0;}
127 | to {opacity:1;}
128 | }
129 |
130 | .shadow {
131 | box-shadow: 0 0 0 .5px rgba(49,49,93,.03),0 2px 5px 0 rgba(49,49,93,.1),0 1px 2px 0 rgba(0,0,0,.08) !important;
132 | }
133 |
134 | .badge[data-badge]::after {
135 | background: $gray-color-dark;
136 | font-size: 0.6rem;
137 | height: 0.8rem;
138 | line-height: 0.6rem;
139 | }
140 | .badge.badge-inline::after {
141 | transform: none;
142 | }
143 |
144 | .tab {
145 | border-bottom-color: $gray-color-light;
146 | }
147 |
148 | .tile-quickstart:hover {
149 | .tile-actions-q a {
150 | color: $gray-color !important;
151 |
152 | &:hover {
153 | color: $primary-color !important;
154 | }
155 | }
156 | }
157 |
158 | .tile-actions-q a {
159 | color: $light-color;
160 | }
161 |
162 | .icon-crown{
163 | color: $gold-color !important;
164 | }
165 |
166 | @media (--breakpoint-medium){
167 | .feed{
168 | display: flex;
169 | }
170 | .post-active .feed{
171 | display: none;
172 | }
173 | .post{
174 | display: none;
175 | }
176 | .post-active .post{
177 | display: flex;
178 | }
179 | }
180 |
181 | ::-webkit-scrollbar-track {
182 | -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
183 | background-color: $lighter-shade;
184 | }
185 |
186 | ::-webkit-scrollbar {
187 | width: 6px;
188 | background-color: $lighter-shade;
189 | }
190 |
191 | ::-webkit-scrollbar-thumb {
192 | background-color: $gray-color;
193 | }
--------------------------------------------------------------------------------
/src/themes/autumn/_components.scss:
--------------------------------------------------------------------------------
1 |
2 | @import "components/feed";
3 | @import "components/post";
4 | @import "components/navbar";
5 | @import "components/publish";
6 | @import "components/profile";
7 | @import "components/tributejs";
8 | @import "components/chat";
9 |
10 | @import "components/account";
11 | @import "components/modal";
--------------------------------------------------------------------------------
/src/themes/autumn/_layout.scss:
--------------------------------------------------------------------------------
1 |
2 | .main-reader {
3 | max-width: $layout-max-width;
4 | margin: 0 auto;
5 | }
6 |
7 | main.board {
8 | max-width: $layout-max-width;
9 | width: 100%;
10 | margin: 0 auto;
11 | padding: 0 0.5rem;
12 |
13 | &::before {
14 | content: "";
15 | display: table;
16 | }
17 | &::after {
18 | content: "";
19 | display: table;
20 | clear: both;
21 | }
22 |
23 | & > section:nth-child(2) {
24 | display: none;
25 | }
26 |
27 | &.post-active > section:nth-child(1) {
28 | display: none;
29 | }
30 |
31 | &.post-active > section:nth-child(2) {
32 | display: flex;
33 | max-width: 100%;
34 | width: 100%;
35 | }
36 |
37 | @media screen and (min-width: $size-xs) {
38 | padding: 0 1rem;
39 |
40 | & > section.feed {
41 | width: calc(99.9% * 1/3 - (30px - 30px * 1/3));
42 | min-width: 392px;
43 | }
44 |
45 | &.post-active > section.feed {
46 | display: flex;
47 | }
48 |
49 | & > section:nth-child(2) {
50 | display: flex;
51 | width: calc(99.9% * 2/3 - (30px - 30px * 2/3));
52 | float: left;
53 | margin-right: 30px;
54 | clear: none;
55 | }
56 |
57 | & > section:nth-child(2):last-child {
58 | margin-right: 0;
59 | }
60 |
61 | & > section.feed:nth-child(1n) {
62 | float: left;
63 | margin-right: 30px;
64 | clear: none;
65 | }
66 |
67 | section.feed:nth-child(3n + 1) {
68 | clear: both;
69 | }
70 | }
71 | }
72 |
73 | main.publish {
74 | padding: 0 0 1rem;
75 |
76 | & > section {
77 | width: 40rem;
78 | margin: 0 auto;
79 | }
80 | }
--------------------------------------------------------------------------------
/src/themes/autumn/_variables.scss:
--------------------------------------------------------------------------------
1 | // Core colors
2 | $primary-color: #3D5AFE !default;
3 | $secondary-color: lighten($primary-color, 37.5%) !default;
4 |
5 | // Gray colors
6 | // Comment for dark
7 |
8 | $dark-color: #454d5d !default;
9 | $light-color: #fff !default;
10 | $gray-color: lighten($dark-color, 40%) !default;
11 | $gray-color-dark: darken($gray-color, 30%) !default;
12 | $gray-color-light: lighten($gray-color, 20%) !default;
13 |
14 | // Uncomment for dark
15 | /*
16 | $dark-color: rgb(196, 196, 196) !default;
17 | $light-color: #292929 !default;
18 | $gray-color: lighten($light-color, 40%) !default;
19 | $gray-color-dark: lighten($gray-color, 25%) !default;
20 | $gray-color-light: darken($gray-color, 20%) !default;
21 | */
22 |
23 |
24 | // Extra colors
25 | $dark-shade: desaturate(mix($light-color, $primary-color, 80%), 60%);
26 | $light-shade: desaturate(mix($light-color, $primary-color, 92%), 50%);
27 | $lighter-shade: desaturate(mix($light-color, $primary-color, 96%), 50%);
28 | $darkest-primary: mix($dark-color, $primary-color, 90%);
29 | $darker-primary: mix($dark-color, $primary-color, 30%);
30 | $gold-color: #ffb700;
31 |
32 | // Font sizes
33 | $html-font-size: 20px !default;
34 | $html-line-height: 1.5 !default;
35 | $font-size: .75rem !default;
36 | $font-size-sm: .6rem !default;
37 | $font-size-lg: .9rem !default;
38 | $line-height: 1rem !default;
39 |
40 | // Responsive breakpoints
41 | $size-xs: 480px !default;
42 | $size-sm: 600px !default;
43 | $size-md: 840px !default;
44 | $size-lg: 960px !default;
45 | $size-xl: 1280px !default;
46 | $size-2x: 1440px !default;
47 |
48 | $responsive-breakpoint: $size-xs !default;
49 |
50 | $layout-max-width: $size-xl;
--------------------------------------------------------------------------------
/src/themes/autumn/autumn.scss:
--------------------------------------------------------------------------------
1 | /*!
2 | * Anzu - Autumn
3 | * @version v0.0.1
4 | * @link https://tryanzu.com
5 | * Copyright (c) 2019 Anzu
6 | * @license
7 | */
8 | @import "variables";
9 |
10 | /* Base (Tachyons/Spectre/Typicons) source files */
11 | @import "~react-toastify/dist/ReactToastify.css";
12 |
13 | // Import only the needed components
14 | @import "~spectre.css/src/spectre";
15 | @import "~spectre.css/src/spectre-exp";
16 |
17 | // Import extra
18 | @import "typicons";
19 | @import "layout";
20 | @import "common";
21 | @import "components";
22 |
23 | // Tachyons is our source of truth
24 | @import "~tachyons/css/tachyons";
--------------------------------------------------------------------------------
/src/themes/autumn/components/_account.scss:
--------------------------------------------------------------------------------
1 | .account-modal {
2 | z-index: 301;
3 | width: 320px;
4 | max-width: 100%;
5 | margin: 1rem auto;
6 |
7 | &:focus {
8 | outline-color: $light-shade;
9 | }
10 |
11 | & .modal-container {
12 | margin: 1rem auto;
13 | padding: 0;
14 | max-width: 100%;
15 | }
16 |
17 | & .form-group:not(:last-child) {
18 | margin-bottom: 0.8rem;
19 | }
20 |
21 | & li.tab-item a {
22 | padding-top: 0.8rem;
23 | padding-bottom: 0.8rem;
24 | }
25 | }
26 |
27 | @media screen and (min-width: 30em) {
28 | .account-modal {
29 | width: 380px;
30 | margin: 4rem auto;
31 | }
32 | }
--------------------------------------------------------------------------------
/src/themes/autumn/components/_chat.scss:
--------------------------------------------------------------------------------
1 | main.chat {
2 | max-width: $layout-max-width;
3 | width: 100%;
4 | width: calc(100% - 2rem);
5 | margin: 0 auto 1rem;
6 | padding: 0;
7 | background: $light-color;
8 | border-bottom-right-radius: 4px;
9 | border-bottom-left-radius: 4px;
10 | box-shadow: 0 7px 14px 0 rgba(50,50,93,.1), 0 3px 6px 0 $gray-color-light;
11 |
12 | & header {
13 | border-bottom: 2px solid $gray-color-light;
14 | }
15 |
16 | & form {
17 | border-top: 1px solid $gray-color-light;
18 | }
19 |
20 | &::before {
21 | content: "";
22 | display: table;
23 | }
24 |
25 | &::after {
26 | content: "";
27 | display: table;
28 | clear: both;
29 | }
30 |
31 | .tile-subtitle p:last-child {
32 | margin-bottom: 0;
33 | }
34 |
35 | .online {
36 | color: $dark-color !important;
37 | }
38 |
39 | small.time {
40 | color: $light-color;
41 | }
42 |
43 | .tile:hover {
44 | background: $light-shade;
45 |
46 | small.time {
47 | color: $gray-color !important;
48 | }
49 |
50 | .tile-actions a {
51 | color: $gray-color !important;
52 |
53 | &:hover {
54 | color: $primary-color !important;
55 | }
56 | }
57 | }
58 |
59 | .tile-actions a {
60 | color: $light-color;
61 | }
62 |
63 | .avatar {
64 | border-radius: 50%;
65 | }
66 |
67 | #video {
68 | background: $dark-color;
69 | text-align: center;
70 | }
71 |
72 | #channels {
73 | min-width: 200px;
74 |
75 | & > div {
76 | border-right: 2px solid $gray-color-light;
77 | }
78 |
79 | .peers {
80 | border-top: 2px solid $gray-color-light;
81 | height: 200px;
82 |
83 | h4 {
84 | font-size: $font-size;
85 | }
86 |
87 | nav a {
88 | color: $body-font-color;
89 | font-weight: 500;
90 |
91 | i {
92 | color: $success-color;
93 | }
94 | }
95 | }
96 |
97 | nav a {
98 | font-weight: 500;
99 | cursor: pointer;
100 |
101 | &.active {
102 | background: $light-shade;
103 | text-decoration: none;
104 | }
105 | }
106 | }
107 |
108 | .starred {
109 | position: -webkit-sticky; /* Safari */
110 | position: sticky;
111 | top: 0;
112 | z-index: 99;
113 | padding: 1rem;
114 |
115 | & > div {
116 | background: #FFFFF0;
117 | border-radius: 4px;
118 | padding: 0.25rem 0.5rem;
119 | box-shadow: 0 4px 6px rgba(50,50,93,.11), 0 1px 3px rgba(0,0,0,.08);
120 | }
121 |
122 | h5 {
123 | font-size: $font-size;
124 |
125 | i {
126 | color: $gold-color;
127 | }
128 | }
129 | }
130 |
131 | .mentions .form-input__input {
132 | width: 100% !important;
133 | height: 100%;
134 | border: 0;
135 | padding: 0.35rem 0.4rem;
136 |
137 | &:focus {
138 | box-shadow: 0 0 0 0.1rem rgba(61, 90, 254, 0.2);
139 | border-color: #3D5AFE !important;
140 | }
141 | }
142 | }
--------------------------------------------------------------------------------
/src/themes/autumn/components/_feed.scss:
--------------------------------------------------------------------------------
1 | .feed {
2 | padding-bottom: 1rem;
3 |
4 | & .tabs {
5 | background: $light-color;
6 | outline: 1px solid $gray-color-light;
7 | border-top-left-radius: 4px;
8 | border-top-right-radius: 4px;
9 | padding: 0.7rem 1rem 0;
10 | position: relative;
11 |
12 | & h2 {
13 | font-size: 0.75rem;
14 | margin: 0;
15 | }
16 |
17 | & .tab {
18 | margin: 0;
19 | }
20 |
21 | & .tab-item a {
22 | padding: 0 0 .2rem;
23 | }
24 |
25 | & .new-posts {
26 | position: absolute;
27 | top: 100%;
28 | width: calc(100% - 2rem);
29 | text-decoration: underline;
30 | text-align: center;
31 | margin-top: 1rem;
32 |
33 | & a {
34 | cursor: pointer;
35 | }
36 | }
37 |
38 | & .filters {
39 | & nav {
40 | margin-left: -0.3rem;
41 | }
42 |
43 | & nav a {
44 | padding: 0.3rem 0.3rem 0.8rem;
45 | display: inline-block;
46 | color: $darkest-primary;
47 | text-decoration: none;
48 |
49 | &.active {
50 | color: $primary-color;
51 | font-weight: 500;
52 | position: relative;
53 |
54 | &:after, &:before {
55 | bottom: -1px;
56 | left: 50%;
57 | border: solid transparent;
58 | content: " ";
59 | height: 0;
60 | width: 0;
61 | position: absolute;
62 | pointer-events: none;
63 | z-index: 1;
64 | }
65 |
66 | &:after {
67 | border-color: rgba(246, 249, 252, 0);
68 | border-bottom-color: $light-shade;
69 | border-width: 5px;
70 | margin-left: -5px;
71 | }
72 |
73 | &:before {
74 | border-color: rgba(225, 231, 237, 0);
75 | border-bottom-color: $gray-color-light;
76 | border-width: 8px;
77 | margin-left: -8px;
78 | }
79 | }
80 | }
81 | }
82 |
83 | & .categories {
84 | padding-bottom: 0.8rem;
85 |
86 | & ul.menu {
87 | width: 345px;
88 | max-width: 100%;
89 | }
90 | }
91 | }
92 |
93 | & .list {
94 | background: $lighter-shade;
95 | border-bottom-right-radius: 4px;
96 | border-bottom-left-radius: 4px;
97 | box-shadow: 0 7px 14px 0 rgba(50,50,93,.1), 0 3px 6px 0 $gray-color-light;
98 | overflow-y: scroll;
99 |
100 | & .ago {
101 | font-size: 0.6rem;
102 | }
103 |
104 | & article {
105 | padding: 0.5rem 1rem;
106 | border-left: 2px solid transparent;
107 | border-bottom: 1px solid $gray-color-light;
108 | cursor: pointer;
109 |
110 | &.active {
111 | border-left: 2px solid $primary-color;
112 | background: $light-color;
113 |
114 | h1 a {
115 | color: $primary-color;
116 | }
117 | }
118 |
119 | &:hover {
120 | background: $light-color;
121 | }
122 |
123 | &:first-child, &:last-child {
124 | padding: 1rem;
125 | }
126 |
127 | & a.category {
128 | color: $gray-color-dark;
129 | display: inline-block;
130 | padding-bottom: 0.25rem;
131 | font-size: 0.6rem;
132 | }
133 |
134 |
135 |
136 | & .new-comments {
137 | background: #38C172;
138 | font-weight: 700;
139 | color: #FFF;
140 | padding: 0.1rem;
141 | border-radius: 4px;
142 | margin-left: .5rem;
143 | }
144 |
145 | & .pinned-post {
146 | color: #38C172;
147 | }
148 |
149 | & h1 {
150 | font-size: 0.8rem;
151 | font-weight: 600;
152 | margin: 0 0 0.25rem;
153 |
154 | & a {
155 | color: $darkest-primary;
156 | }
157 | }
158 | }
159 | }
160 | }
161 |
162 | a[rel="author"], .author {
163 | color: $darkest-primary;
164 | display: flex;
165 | align-items: center;
166 |
167 | &:hover {
168 | text-decoration: none;
169 | }
170 |
171 | & > div:first-child {
172 | min-width: 32px;
173 | }
174 |
175 | & small.ago, & time {
176 | color: $gray-color;
177 | font-weight: normal;
178 | font-size: 12px;
179 | }
180 |
181 | & > div {
182 | &.top {
183 | vertical-align: top;
184 | }
185 |
186 | & > span {
187 | display: flex;
188 | }
189 |
190 | & .reputation {
191 | font-weight: normal;
192 | font-size: 12px;
193 | color: $gray-color-dark;
194 | }
195 |
196 | & small.ago, & span.ago {
197 | color: $gray-color-dark;
198 | }
199 |
200 | & p.bio {
201 | font-weight: normal;
202 | font-size: 12px;
203 | color: $gray-color-dark;
204 | margin-top: 0.25rem;
205 | }
206 | }
207 |
208 | & img, & .empty-avatar {
209 | width: 32px;
210 | height: 32px;
211 | border-radius: 16px;
212 | }
213 |
214 | & .empty-avatar {
215 | background: $primary-color;
216 | color: $light-color;
217 | line-height: 32px;
218 | text-align: center;
219 | }
220 |
221 | & > div:nth-child(2) {
222 | padding-left: 0.5rem;
223 | }
224 | }
225 |
226 |
227 | .feed-sidebar {
228 | border: 0;
229 | }
230 |
231 | .feed-sidebar .category-selector #actions {
232 | background-color: transparent;
233 | border-bottom: 0;
234 | }
235 |
--------------------------------------------------------------------------------
/src/themes/autumn/components/_modal.scss:
--------------------------------------------------------------------------------
1 | .config-modal, .users-modal {
2 | z-index: 301;
3 | width: 640px;
4 | max-width: 100%;
5 | margin: 2rem auto;
6 |
7 | &:focus {
8 | outline-color: $light-shade;
9 | }
10 |
11 | & .modal-container {
12 | padding: 0;
13 | }
14 | }
15 |
16 | .modal.active .modal-overlay, .modal:target .modal-overlay {
17 | background: rgba(17, 31, 68, 0.80) !important;
18 | }
19 |
20 | .modal .form-group:not(:last-child) {
21 | margin-bottom: 0.8rem;
22 | }
23 |
24 | .modal .tab .tab-item a {
25 | padding: .6rem .2rem .6rem;
26 | }
27 |
28 | .form-checkbox, .form-radio, .form-switch {
29 | font-weight: normal;
30 | }
31 |
32 | .modal-container.config {
33 | & .overflow-container {
34 | max-height: 80vh;
35 | }
36 | & nav {
37 | background: $lighter-shade;
38 | border-right: 1px solid $light-shade;
39 | width: 25%;
40 | padding: 0.6rem 0;
41 | flex-shrink: 0;
42 |
43 | & a {
44 | display: block;
45 | color: #50596c;
46 | padding: 0.6rem 1rem;
47 | cursor: pointer;
48 |
49 | &.active, &:hover {
50 | color: $primary-color;
51 | text-decoration: none;
52 | }
53 |
54 | &.active {
55 | font-weight: 500;
56 | }
57 | }
58 | }
59 |
60 | & .flex > div {
61 | max-height: 600px;
62 | overflow-y: scroll;
63 |
64 | & .header {
65 | margin: 0 0 1rem;
66 | }
67 |
68 | & h2 {
69 | font-size: 1.2rem;
70 | font-weight: 500;
71 | margin: 0;
72 | }
73 |
74 | & label {
75 | font-weight: 500;
76 | }
77 | }
78 | }
79 | .feedback-modal {
80 | width: 360px;
81 | z-index: 301;
82 | margin: 1rem auto;
83 |
84 | &:focus {
85 | outline-color: $light-shade;
86 | }
87 |
88 | & .modal-container {
89 | padding: 0.8rem;
90 | }
91 |
92 | & .form-group:not(:last-child) {
93 | margin-bottom: 0.8rem;
94 | }
95 | }
96 |
97 | .chat-config-modal {
98 | max-width: 360px;
99 | z-index: 301;
100 | margin: 1rem auto;
101 |
102 | & .modal-body {
103 | max-height: 80vh;
104 | }
105 |
106 | &:focus {
107 | outline-color: $light-shade;
108 | }
109 |
110 | & .modal-container {
111 | padding: 0.8rem;
112 | }
113 |
114 | & .form-group:not(:last-child) {
115 | margin-bottom: 0.8rem;
116 | }
117 | }
118 |
119 | .modal-body-flagpost {
120 | max-height: 50vh;
121 | overflow-y: auto;
122 | position: relative;
123 | }
124 |
--------------------------------------------------------------------------------
/src/themes/autumn/components/_navbar.scss:
--------------------------------------------------------------------------------
1 | header.navbar-tools {
2 | background: $darkest-primary;
3 |
4 | & > nav {
5 | display: flex;
6 | max-width: $layout-max-width;
7 | margin: 0 auto;
8 | padding: 0.5rem 1rem;
9 | color: $light-color;
10 | font-size: 14px;
11 |
12 | & a {
13 | cursor: pointer;
14 | color: $light-color;
15 | }
16 |
17 | & .badge:not([data-badge])::after, & .badge[data-badge]::after {
18 | background: #38C172;
19 | box-shadow: 0 0 0 0.1rem #191E38;
20 | }
21 | }
22 | }
23 |
24 | header.navbar {
25 | background-color: transparent;
26 | border-bottom: transparent;
27 | padding: 1rem 1rem;
28 | max-width: $layout-max-width;
29 | margin: 0 auto;
30 |
31 | & h1.logo {
32 | color: $primary-color;
33 | margin: 0;
34 | text-transform: lowercase;
35 | }
36 |
37 | & img.logo {
38 | min-width: 65px;
39 | }
40 |
41 | & input[type="search"] {
42 | font-size: 0.8rem;
43 | margin-left: 1rem;
44 | border: 1px solid $dark-shade;
45 | border-radius: 25px;
46 | background: $dark-shade;
47 | padding: 0.25rem 1rem;
48 | width: 110px;
49 |
50 | &.extended {
51 | width: 220px;
52 | }
53 |
54 | &:hover {
55 | background: $light-color;
56 | }
57 |
58 | &:focus {
59 | outline: none;
60 | background: $light-color;
61 | border-color: $primary-color;
62 | color: $primary-color;
63 | }
64 | }
65 |
66 | & a.btn.btn-link, & span.badge {
67 | color: $darkest-primary;
68 | margin: 0 0.8rem 0 0;
69 | padding-left:0px;
70 | padding-right: 0px;
71 | }
72 |
73 | & .new-reputation {
74 | background: #38C172;
75 | font-weight: 700;
76 | color: #FFF;
77 | padding: 0.1rem;
78 | border-radius: 4px;
79 | margin-left: .5rem;
80 |
81 | &.lost {
82 | background: $error-color;
83 | }
84 | }
85 |
86 | & .menu.notifications {
87 | width: 300px;
88 | padding: 0;
89 |
90 | @media screen and (min-width: 30em) {
91 | width: 360px;
92 | }
93 |
94 | & li.menu-item {
95 | padding: 0;
96 | margin: 0;
97 | }
98 |
99 | & li.menu-item a.link {
100 | padding: 0.5rem 1.5rem;
101 | border-bottom: 1px solid $light-shade;
102 | line-height: 1.5;
103 |
104 | &:focus, &:hover {
105 | background-color: $light-shade;
106 | outline: 0;
107 | box-shadow: none;
108 | }
109 |
110 | & .clean-styles {
111 | color: $darker-primary;
112 | }
113 | }
114 | }
115 | }
116 |
117 |
118 | header.navbar .badge {
119 | background-color: transparent;
120 | color: $darkest-primary;
121 | padding: 0.5rem 1rem;
122 |
123 | &.none::after {
124 | display: none;
125 | }
126 | }
127 |
128 | header.navbar .badge:not([data-badge])::after, header.navbar .badge[data-badge]::after {
129 | box-shadow: none;
130 | }
--------------------------------------------------------------------------------
/src/themes/autumn/components/_post.scss:
--------------------------------------------------------------------------------
1 | .current-article {
2 | background: $light-color;
3 | border-radius: 4px;
4 | box-shadow: 0 7px 14px 0 rgba(50,50,93,.1), 0 3px 6px 0 $gray-color-light;
5 | padding: 1rem;
6 | overflow-x: hidden;
7 | overflow-y: auto;
8 |
9 | & section h1, & article h1 {
10 | color: $darker-primary;
11 | font-size: 1.1rem;
12 | }
13 |
14 | & section h2, & article h2 {
15 | font-size: 1rem;
16 | }
17 |
18 | & section h3, & article h3 {
19 | font-size: 0.8rem;
20 | }
21 |
22 | & section > h1, & section h2 {
23 | margin-top: 0;
24 | margin-bottom: 0.8rem;
25 | }
26 |
27 | & .actions {
28 | margin: 0 -1rem;
29 | padding: 0 1rem 0.5rem;
30 | }
31 |
32 | & section, & article {
33 | line-height: 1.5;
34 | & .separator {
35 | content: " ";
36 | width: 100%;
37 | border-bottom: 1px solid $gray-color-light;
38 | margin: .8rem 0;
39 | }
40 | }
41 |
42 | & .asset {
43 | border: 1px solid var(--mainGray);
44 | max-width: 300px;
45 | }
46 |
47 | & textarea.form-input {
48 | box-shadow: 0 0 0 0.5px rgba(50,50,93,.27), 0 0 0 0.5px rgba(50,151,211,0), 0 0 0 2px rgba(50,151,211,0), 0 0.5px 1px rgba(0,0,0,.08);
49 | border: 1px solid #e5e5e5;
50 | border-radius: 2px;
51 |
52 | &:focus {
53 | border-color: var(--primaryColor);
54 | }
55 | }
56 |
57 | & .new-comments {
58 | margin-bottom: 2rem;
59 | text-decoration: underline;
60 | text-align: center;
61 | & a {
62 | cursor: pointer;
63 | }
64 | }
65 |
66 | & .nested-replies {
67 | padding-left: 1rem;
68 |
69 | & .comment {
70 | border-bottom: 0;
71 | padding-top: 0.5rem;
72 | padding-bottom: 0;
73 |
74 | & .comment-body {
75 | padding-top: 0;
76 | padding-left: 1.5rem;
77 | }
78 | }
79 | }
80 |
81 | & .comment-body {
82 | padding: 0.5rem 0 0;
83 | line-height: 1.5;
84 |
85 | & p:last-child {
86 | margin-bottom: 0;
87 | }
88 | }
89 |
90 | & .feedback {
91 | margin: 1rem -2rem 0;
92 | padding: 0 2rem;
93 | }
94 |
95 | & .feedback a {
96 | margin: 0 10px;
97 | font-size: 18px;
98 | line-height: 18px;
99 | color: $dark-color;
100 | cursor: pointer;
101 | & .icon {
102 | margin-right: 0.5rem;
103 | }
104 | & .type {
105 | padding: 0 0.5rem;
106 | display: none;
107 | }
108 |
109 | &:last-child {
110 | margin-right: 0;
111 | }
112 |
113 | &.active {
114 | color: $primary-color;
115 | text-decoration: none;
116 | }
117 |
118 | &.active[data-badge]::after {
119 | background: $success-color;
120 | }
121 |
122 | &:hover {
123 | color: $primary-color;
124 | }
125 | }
126 |
127 | & .feedback a:first-of-type {
128 | margin-left: 0;
129 | }
130 |
131 | & .feedback h5 {
132 | color: #777777;
133 | font-size: 12px;
134 | margin-bottom: 0.75rem;
135 | }
136 |
137 | & .feedback .badge:not([data-badge])::after,
138 | & .feedback .badge[data-badge]::after {
139 | font-size: 12px;
140 | }
141 |
142 | @media (--breakpoint-medium){
143 | margin: 0;
144 | padding: 1rem;
145 | }
146 |
147 |
148 | & article.comment {
149 | margin-left: -2rem;
150 | margin-right: -2rem;
151 | padding: 1rem 2rem;
152 | border-bottom: 1px solid $gray-color-light;
153 |
154 | &:hover {
155 | background: $light-shade;
156 | }
157 | }
158 | }
159 |
160 | .quick-guide {
161 |
162 | &:before {
163 | content: '';
164 | display: table;
165 | }
166 |
167 | &:after {
168 | content: '';
169 | display: table;
170 | clear: both;
171 | }
172 |
173 | & > div {
174 | width: calc(99.9% * 1/3 - (30px - 30px * 1/3));
175 | }
176 |
177 | & > div:nth-child(1n) {
178 | float: left;
179 | margin-right: 30px;
180 | clear: none;
181 | }
182 |
183 | & > div:nth-child(3n + 1) {
184 | clear: both;
185 | }
186 |
187 | & > div:nth-child(3n) {
188 | margin-right: 0px;
189 | float: right;
190 | }
191 |
192 | h3 {
193 | font-size: 0.8rem;
194 | margin-bottom: 0.4rem;
195 | }
196 |
197 | p {
198 | font-size: 0.65rem;
199 | line-height: 1rem;
200 |
201 | &:last-child {
202 | margin-bottom: 0;
203 | }
204 | }
205 | }
206 |
207 | .subscribe {
208 | &:before {
209 | content: '';
210 | display: table;
211 | }
212 |
213 | &:after {
214 | content: '';
215 | display: table;
216 | clear: both;
217 | }
218 |
219 | & > div {
220 | width: calc(99.9% * 1/2 - (30px - 30px * 1/2));
221 | }
222 |
223 | & > div:nth-child(1n) {
224 | float: left;
225 | margin-right: 30px;
226 | clear: none;
227 | }
228 |
229 | & > div:nth-child(2n + 1) {
230 | clear: both;
231 | }
232 |
233 | & > div:nth-child(2n) {
234 | margin-right: 0px;
235 | float: right;
236 | }
237 |
238 | h3 {
239 | font-size: 0.8rem;
240 | margin-bottom: 0.4rem;
241 | }
242 |
243 | p {
244 | font-size: 0.65rem;
245 | line-height: 1rem;
246 |
247 | &:last-child {
248 | margin-bottom: 0;
249 | }
250 | }
251 | }
252 |
253 | textarea {
254 | overflow: hidden
255 | }
256 |
257 | .reply-form ul li {
258 | margin: 0;
259 | }
260 |
261 | .reply {
262 | padding: 1rem 0;
263 | }
264 |
--------------------------------------------------------------------------------
/src/themes/autumn/components/_profile.scss:
--------------------------------------------------------------------------------
1 | .profile {
2 | & > section {
3 | width: 50rem;
4 | max-width: 100%;
5 | margin: 0 auto;
6 |
7 | & div.boxed {
8 | padding: 1rem;
9 | background: $light-color;
10 | border-radius: 4px;
11 | box-shadow: 0 7px 14px 0 rgba(50,50,93,.1), 0 3px 6px 0 rgba(0,0,0,.07);
12 |
13 | & > h1, & > h2 {
14 | color: $darkest-primary;
15 | font-size: 1.2rem;
16 | }
17 | }
18 | }
19 |
20 | & article.post {
21 | padding: 0.5rem 1rem;
22 | border-bottom: 1px solid $light-color;
23 |
24 | &:hover {
25 | background: $light-color;
26 | }
27 |
28 | &:first-child, &:last-child {
29 | padding: 1rem;
30 | }
31 |
32 | & a.category {
33 | display: inline-block;
34 | padding-bottom: 0.25rem;
35 | font-size: 0.7rem;
36 | }
37 |
38 | & .new-comments {
39 | background: $primary-color;
40 | font-weight: 700;
41 | color: $light-color;
42 | padding: 0.1rem;
43 | border-radius: 4px;
44 | margin-left: .5rem;
45 | }
46 |
47 | & .pinned-post {
48 | color: #e85600
49 | }
50 |
51 | & h1 {
52 | font-size: 0.8rem;
53 | font-weight: 600;
54 | margin: 0 0 0.25rem;
55 |
56 | & a {
57 | color: $darkest-primary;
58 | }
59 | }
60 | }
61 |
62 | & article.comment {
63 | padding: 0.5rem 1rem;
64 | border-bottom: 1px solid var(--mainGray);
65 |
66 | &:hover {
67 | background: $light-color;
68 | }
69 |
70 | &:first-child, &:last-child {
71 | padding: 1rem;
72 | }
73 |
74 | & a.category {
75 | color: $darkest-primary;
76 | display: inline-block;
77 | padding-bottom: 0.25rem;
78 | font-size: 0.8rem;
79 | font-weight: 600;
80 | }
81 |
82 | & > p {
83 | margin-bottom: 10px;
84 | display: -webkit-box;
85 | -webkit-line-clamp: 3;
86 | -webkit-box-orient: vertical;
87 | white-space: initial;
88 | }
89 | }
90 |
91 | & .bar {
92 | background: #b3b7c3;
93 | }
94 | }
--------------------------------------------------------------------------------
/src/themes/autumn/components/_publish.scss:
--------------------------------------------------------------------------------
1 | main.publish {
2 | & .editor hr {
3 | height: 1px;
4 | border: 0;
5 | background: $gray-color-light;
6 | }
7 |
8 | & .editor form {
9 | background: $light-color;
10 | outline: 1px solid $light-shade;
11 | border-radius: 4px;
12 | box-shadow: 0 7px 14px 0 rgba(50,50,93,.1), 0 3px 6px 0 rgba(0,0,0,.07);
13 |
14 | & > h1, & > h2 {
15 | color: $primary-color;
16 | }
17 | }
18 |
19 | & .editor .post-preview {
20 | & h1 {
21 | font-size: 1.3rem;
22 | }
23 |
24 | & h2 {
25 | font-size: 1.2rem;
26 | }
27 |
28 | & h3 {
29 | font-size: 1.1rem;
30 | }
31 | }
32 |
33 | .form-select {
34 | width: auto;
35 | }
36 | .form-select:not([multiple]):not([size]) {
37 | background: none;
38 | }
39 |
40 | .title-input {
41 | font-size: 1.3rem;
42 | font-weight: 600;
43 | border: 0;
44 | padding-left: 0;
45 | padding-right: 0;
46 |
47 | &:focus {
48 | box-shadow: none;
49 | }
50 | }
51 | }
52 |
53 | .RichTextEditor__root___2QXK- {
54 | background: $light-color !important;
55 | border-color: $gray-color !important;
56 | font-family: inherit !important;
57 |
58 | & h1 {
59 | font-size: 1.3rem;
60 | }
61 |
62 | & h2 {
63 | font-size: 1.2rem;
64 | }
65 |
66 | & h3 {
67 | font-size: 1.1rem;
68 | }
69 |
70 | & ul {
71 | list-style: disc outside;
72 | }
73 |
74 | & .ImageSpan__root___RoAqL {
75 | max-width: 100%;
76 | }
77 |
78 | .public-DraftEditor-content {
79 | min-height: 110px;
80 | }
81 |
82 | .Button__root___1gz0c {
83 | background: rgba(255, 255, 255, 0.6) !important;
84 | }
85 | }
86 |
87 | .context-help {
88 | ul {
89 | padding: 0;
90 | margin-top: 0;
91 |
92 | li {
93 | margin-bottom: 0.25rem;
94 | }
95 | }
96 | p {
97 | margin: 0 0 0.5rem;
98 | }
99 | }
--------------------------------------------------------------------------------
/src/themes/autumn/components/_tributejs.scss:
--------------------------------------------------------------------------------
1 | .tribute-container {
2 | position: absolute;
3 | top: 0;
4 | left: 0;
5 | height: auto;
6 | max-height: 300px;
7 | max-width: 500px;
8 | overflow: auto;
9 | display: block;
10 | z-index: 999999; }
11 | .tribute-container ul {
12 | box-shadow: 0 7px 14px 0 rgba(50, 50, 93, .1), 0 3px 6px 0 rgba(0, 0, 0, .07);
13 | border-radius: 2px;
14 | margin: 0;
15 | margin-top: 2px;
16 | padding: 0;
17 | list-style: none;
18 | background: $gray-color-light;
19 | }
20 | .tribute-container li {
21 | padding: 5px 5px;
22 | cursor: pointer; }
23 | .tribute-container li.highlight, .tribute-container li:hover {
24 | background: $primary-color;
25 | color: $light-color;
26 | }
27 | .tribute-container li span {
28 | font-weight: bold; }
29 | .tribute-container li.no-match {
30 | cursor: default; }
31 | .tribute-container .menu-highlighted {
32 | font-weight: bold; }
33 |
--------------------------------------------------------------------------------
/src/themes/autumn/font/typicons.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/themes/autumn/font/typicons.eot
--------------------------------------------------------------------------------
/src/themes/autumn/font/typicons.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/themes/autumn/font/typicons.ttf
--------------------------------------------------------------------------------
/src/themes/autumn/font/typicons.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/themes/autumn/font/typicons.woff
--------------------------------------------------------------------------------
/src/themes/autumn/font/typicons.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tryanzu/frontend/e19b13fa0c988bebc676c0c19ddfd8c3909140e0/src/themes/autumn/font/typicons.woff2
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | import qs from 'query-string';
2 | import { toast } from 'react-toastify';
3 | import { t } from './i18n';
4 |
5 | export function parseQuery(queryString) {
6 | var query = {};
7 | var pairs = (
8 | queryString[0] === '?' ? queryString.substr(1) : queryString
9 | ).split('&');
10 | for (var i = 0; i < pairs.length; i++) {
11 | var pair = pairs[i].split('=');
12 | query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '');
13 | }
14 | return query;
15 | }
16 |
17 | export function request(url, params = {}) {
18 | let init = { ...params };
19 | init.method = init.method || 'GET';
20 | init.headers = init.headers || {};
21 | const isFormData = init.body instanceof window.FormData;
22 | if ('body' in init && typeof init.body === 'object' && !isFormData) {
23 | init.body = JSON.stringify(init.body);
24 | init.headers['Content-Type'] = 'application/json';
25 | }
26 | if ('_authToken' in window && window._authToken !== false) {
27 | init.headers.Authorization = `Bearer ${window._authToken}`;
28 | }
29 | if ('query' in init) {
30 | url = url + '?' + qs.stringify(init.query);
31 | delete init.query;
32 | }
33 |
34 | let req = new window.Request(Anzu.layer + url, init);
35 | return window.fetch(req).then(response => {
36 | // Unauthorized response handler.
37 | if (response.status === 401 && (init.headers.Authorization || false)) {
38 | window._authToken = false;
39 | window.localStorage.removeItem('_auth');
40 | return request(url, params);
41 | }
42 | return response;
43 | });
44 | }
45 |
46 | /**
47 | * Try/catch error wrapper.
48 | * Allows to simplify exception handling on fractals.
49 | * @param {*} fn wrapped in try/catch
50 | * @param {*} errorCode shown error code on exception.
51 | */
52 | export function attemptOR(fn, reducer = stopWorkingReducer) {
53 | try {
54 | return fn();
55 | } catch (message) {
56 | console.error(message);
57 | return showErrAndReduce(t`${message}`, reducer);
58 | }
59 | }
60 |
61 | /**
62 | * Dummy reducer that sets the working state property to false.
63 | */
64 | export function stopWorkingReducer() {
65 | return state => ({ ...state, working: false });
66 | }
67 |
68 | export function showErrAndReduce(message, reducer) {
69 | toast.error(message);
70 | return reducer;
71 | }
72 |
73 | export function successAndReduce(message, reducer) {
74 | toast.success(message);
75 | return reducer;
76 | }
77 |
--------------------------------------------------------------------------------
/webpack.common.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const path = require('path');
3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin');
4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
5 |
6 | module.exports = {
7 | entry: {
8 | main: './src/mount.js',
9 | autumn: './src/themes/autumn/autumn.scss',
10 | },
11 | mode: 'development',
12 | output: {
13 | path: path.resolve(__dirname, 'public/dist'),
14 | filename: '[name].bundle.js',
15 | hashFunction: 'sha256',
16 | },
17 | // optimization: {
18 | // splitChunks: {
19 | // cacheGroups: {
20 | // styles: {
21 | // name: 'theme',
22 | // test: /\.s[ac]ss$/i,
23 | // chunks: 'all',
24 | // enforce: true,
25 | // },
26 | // },
27 | // },
28 | // },
29 | resolve: {
30 | fallback: { path: require.resolve('path-browserify'), process: false },
31 | },
32 | plugins: [
33 | //new CleanWebpackPlugin(),
34 | new webpack.DefinePlugin({
35 | CURRENT_VERSION: JSON.stringify(process.env.npm_package_version),
36 | }),
37 | new MiniCssExtractPlugin({
38 | // Options similar to the same options in webpackOptions.output
39 | // both options are optional
40 | filename: '[name].bundle.css',
41 | }),
42 | ],
43 | module: {
44 | rules: [
45 | {
46 | test: /\.js$/,
47 | exclude: /node_modules\/(?!tributejs)/,
48 | use: {
49 | loader: 'babel-loader',
50 | options: {
51 | presets: ['@babel/preset-env'],
52 | },
53 | },
54 | },
55 | {
56 | test: /\.s[ac]ss$/i,
57 | use: [
58 | {
59 | loader: MiniCssExtractPlugin.loader,
60 | options: {
61 | publicPath: '',
62 | },
63 | },
64 | // Translates CSS into CommonJS
65 | 'css-loader',
66 | // Compiles Sass to CSS
67 | 'sass-loader',
68 | ],
69 | },
70 | {
71 | test: /\.(woff|woff2|eot|ttf|otf|svg)$/,
72 | use: ['file-loader'],
73 | },
74 | ],
75 | },
76 | };
77 |
--------------------------------------------------------------------------------
/webpack.dev.js:
--------------------------------------------------------------------------------
1 | const { merge } = require('webpack-merge');
2 | const common = require('./webpack.common.js');
3 | module.exports = merge(common, {
4 | mode: 'development',
5 | devtool: 'inline-source-map',
6 | devServer: {
7 | contentBase: './dist',
8 | },
9 | });
10 |
--------------------------------------------------------------------------------
/webpack.prod.js:
--------------------------------------------------------------------------------
1 | const { merge } = require('webpack-merge');
2 | const common = require('./webpack.common.js');
3 | const MinifyPlugin = require("babel-minify-webpack-plugin");
4 | const TerserPlugin = require('terser-webpack-plugin');
5 |
6 | module.exports = merge(common, {
7 | mode: 'production',
8 | plugins: [
9 | new MinifyPlugin()
10 | ]
11 | });
--------------------------------------------------------------------------------