├── .gitignore ├── LICENSE ├── README.md ├── common ├── __init__.py ├── apps.py ├── config.py ├── forms.py ├── management │ └── commands │ │ ├── delete_job.py │ │ └── list_jobs.py ├── models.py ├── static │ ├── css │ │ ├── boofilsic.css │ │ ├── boofilsic.min.css │ │ ├── boofilsic_box.css │ │ ├── boofilsic_browse.css │ │ └── boofilsic_edit.css │ ├── fonts │ │ └── MaterialIcons.woff2 │ ├── img │ │ ├── fediverse.svg │ │ ├── logo.svg │ │ ├── logo_blue.png │ │ ├── logo_square.jpg │ │ ├── logo_square.png │ │ └── logo_square.svg │ ├── js │ │ ├── create_update_review.js │ │ ├── detail.js │ │ ├── home.js │ │ ├── key_value_input.js │ │ ├── mastodon.js │ │ ├── rating-star-readonly.js │ │ ├── scrape.js │ │ ├── sort_layout.js │ │ └── topic.js │ ├── opensearch.xml │ ├── react │ │ ├── axios.js │ │ ├── editor.css │ │ ├── editor.js │ │ ├── group.js │ │ └── index.css │ └── sass │ │ ├── _AsideSection.sass │ │ ├── _Blockquote.sass │ │ ├── _Button.sass │ │ ├── _Code.sass │ │ ├── _Color.sass │ │ ├── _Divider.sass │ │ ├── _Footer.sass │ │ ├── _Form.sass │ │ ├── _Global.sass │ │ ├── _Grid.sass │ │ ├── _Icon.sass │ │ ├── _Image.sass │ │ ├── _Label.sass │ │ ├── _List.sass │ │ ├── _MainSection.sass │ │ ├── _Modal.sass │ │ ├── _Navbar.sass │ │ ├── _Pagination.sass │ │ ├── _SingleSection.sass │ │ ├── _Spacing.sass │ │ ├── _Table.sass │ │ ├── _Typography.sass │ │ ├── _Utility.sass │ │ ├── _Vendor.sass │ │ └── boofilsic.sass ├── templates │ ├── 503.html │ ├── common │ │ └── error.html │ ├── partial │ │ ├── _announcement.html │ │ ├── _footer.html │ │ ├── _navbar.html │ │ └── _sidebar.html │ └── widgets │ │ ├── hstore.html │ │ ├── image.html │ │ ├── multi_select.html │ │ └── tag.html ├── templatetags │ ├── __init__.py │ ├── admin_url.py │ ├── duration.py │ ├── highlight.py │ ├── mastodon.py │ ├── nav.py │ ├── oauth_token.py │ ├── prettydate.py │ ├── strip_scheme.py │ ├── thumb.py │ └── truncate.py ├── tests.py ├── urls.py ├── utils.py └── views.py ├── group ├── __init__.py ├── admin.py ├── apps.py ├── feeds.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_group_name.py │ ├── 0003_comment_comment_reply.py │ ├── 0004_likecomment.py │ └── __init__.py ├── models.py ├── schema.py ├── static │ └── catalog.js ├── templates │ ├── group │ │ ├── base.html │ │ ├── create.html │ │ ├── group.html │ │ ├── group_edit.html │ │ ├── home.html │ │ ├── new_topic.html │ │ ├── profile.html │ │ ├── react_base.html │ │ ├── react_new_topic.html │ │ ├── sidebar.html │ │ ├── sidebar_group.html │ │ └── topic.html │ ├── libs │ │ └── common.html │ └── react_print.html ├── templatetags │ ├── __init__.py │ └── neogroup.py ├── tests.py ├── urls.py └── views.py ├── manage.py ├── mastodon ├── __init__.py ├── admin.py ├── api.py ├── apps.py ├── auth.py ├── decorators.py ├── management │ └── commands │ │ └── wrong_sites.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py └── utils.py ├── neogroup ├── __init__.py ├── asgi.py ├── context_processors.py ├── settings.py ├── urls.py └── wsgi.py ├── requirements.txt ├── static_source ├── hypernova-bootstrap.js ├── package.json ├── src │ ├── App.jsx │ ├── common │ │ ├── axios.js │ │ ├── color.scss │ │ ├── isServer.js │ │ ├── sidebar.scss │ │ ├── utils.jsx │ │ ├── utils.scss │ │ ├── withHypernova.js │ │ └── wrapInBaseContainer.jsx │ ├── components │ │ ├── Author │ │ │ ├── index.jsx │ │ │ └── style.scss │ │ ├── Card │ │ │ ├── Group │ │ │ │ ├── index.jsx │ │ │ │ └── style.scss │ │ │ ├── SimpleGroup │ │ │ │ ├── index.jsx │ │ │ │ └── style.scss │ │ │ ├── Topic │ │ │ │ ├── index.jsx │ │ │ │ └── style.scss │ │ │ ├── User │ │ │ │ ├── index.jsx │ │ │ │ └── style.scss │ │ │ └── index.jsx │ │ ├── Comment │ │ │ ├── index.jsx │ │ │ └── style.scss │ │ ├── Editor │ │ │ ├── editor.jsx │ │ │ └── editor.scss │ │ ├── Like │ │ │ ├── index.jsx │ │ │ └── style.scss │ │ ├── Nav │ │ │ ├── index.jsx │ │ │ └── style.scss │ │ ├── Pagination │ │ │ ├── index.jsx │ │ │ └── style.scss │ │ ├── Quote │ │ │ ├── index.jsx │ │ │ └── style.scss │ │ ├── ReplyForm │ │ │ ├── index.jsx │ │ │ └── style.scss │ │ ├── Sidebar │ │ │ ├── GroupHome │ │ │ │ ├── index.jsx │ │ │ │ └── style.scss │ │ │ ├── Home │ │ │ │ ├── index.jsx │ │ │ │ └── style.scss │ │ │ ├── Topic │ │ │ │ ├── index.jsx │ │ │ │ └── style.scss │ │ │ └── index.jsx │ │ └── index.js │ ├── hypernova.js │ ├── index.jsx │ ├── index.scss │ ├── pages │ │ ├── Group │ │ │ ├── index.jsx │ │ │ └── style.scss │ │ ├── Topic │ │ │ ├── index.jsx │ │ │ └── style.scss │ │ └── index.js │ └── reset.css ├── vite.config.js └── yarn.lock └── users ├── __init__.py ├── account.py ├── admin.py ├── apps.py ├── data.py ├── forms.py ├── management └── commands │ ├── backfill_mastodon.py │ ├── disable_user.py │ ├── refresh_following.py │ └── refresh_mastodon.py ├── migrations ├── 0001_initial.py ├── 0002_alter_user_username.py └── __init__.py ├── models.py ├── static └── js │ ├── followers_list.js │ └── following_list.js ├── tasks.py ├── templates └── users │ ├── data.html │ ├── data_import_status.html │ ├── home_anonymous.html │ ├── login.html │ ├── manage_report.html │ ├── preferences.html │ ├── register.html │ ├── relation_list.html │ └── report.html ├── tests.py ├── urls.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # local folder 132 | .history/ 133 | neodb/ 134 | /static/ 135 | markdownx/ 136 | /media/ 137 | dump.rdb 138 | *.crt 139 | *.key 140 | *.pid 141 | /run.sh 142 | node_modules/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Asahi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 开篇 2 | 3 | NeoGroup,灵感来源于 NeoDB,NeoDB 里几乎涵盖了豆瓣全部的书影音功能,但是唯独缺少了小组和同城功能,作为这两个功能的重度使用者,决定做点什么,所以也模仿 NeoDB,开发了一个基于 Mastodon 登录的去中心化小组产品 4 | 5 | # TODO 6 | 7 | - [ ] i18n 8 | - [ ] 首页增加关注的用户的发布的帖子 9 | 10 | ## 前端 Dev 11 | 12 | 使用了 [django-react-templatetags](https://github.com/Frojd/django-react-templatetags) 提供的 django 插件进行前后端(伪)分离渲染,源文件在 `/static_source` 目录,编译至 `/common/static/react/` 文件夹。开发前首先确保安装了 node.js 和 yarn ,然后从根目录执行以下命令: 13 | 14 | ```bash 15 | cd static_source 16 | yarn 17 | yarn dev 18 | ``` 19 | 20 | 以上执行均无问题的话(执行过程可能 OOM 需要[手动设置一下](https://stackoverflow.com/questions/48387040/how-do-i-determine-the-correct-max-old-space-size-for-node-js) ),就可以开始开发啦~ 21 | 22 | --- 23 | 24 | ### 以新建一个 `Nav` 导航栏组件为例 25 | 26 | 首先在 `static_source/src/components/` 下新建一个 `Nav.jsx` 文件( 样式可写在同目录下的 style.scss 里 import ): 27 | 28 | https://github.com/anig1scur/neogroup/blob/style/static_source/src/components/Nav/index.jsx 29 | 30 | 31 | 然后在 [App](https://github.com/anig1scur/neogroup/blob/style/static_source/src/App.jsx) 这里对外导出。 32 | 33 | 34 | 确保 `{% load react %}` 以后可以在 django 模板中使用[如下语句](https://github.com/anig1scur/neogroup/blob/582b697f9ffe0f8fb1d69702c450f5423841cef6/group/templates/group/react_base.html#L28)进行一个组件的渲染: 35 | 36 | ```html 37 | 38 | {% react_render component="Nav" %} 39 | 40 | ``` 41 | 42 | 也可以[传 props ](https://github.com/anig1scur/neogroup/blob/style/group/templates/group/react_topic.html#L12) 到组件中,更多使用请看 [django-react-templatetags](https://github.com/Frojd/django-react-templatetags) 43 | 44 | 45 | ### Server Side Rendering 46 | 47 | ```sh 48 | cd static_source 49 | node hypernova-bootstrap.js 50 | ``` 51 | -------------------------------------------------------------------------------- /common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/common/__init__.py -------------------------------------------------------------------------------- /common/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CommonConfig(AppConfig): 5 | name = "common" 6 | -------------------------------------------------------------------------------- /common/config.py: -------------------------------------------------------------------------------- 1 | # how many items are showed in one search result page 2 | ITEMS_PER_PAGE = 20 3 | 4 | # how many pages links in the pagination 5 | PAGE_LINK_NUMBER = 7 6 | 7 | # max tags on list page 8 | TAG_NUMBER_ON_LIST = 5 9 | 10 | # how many books have in each set at the home page 11 | BOOKS_PER_SET = 5 12 | 13 | # how many movies have in each set at the home page 14 | MOVIES_PER_SET = 5 15 | 16 | # how many music items have in each set at the home page 17 | MUSIC_PER_SET = 5 18 | 19 | # how many games have in each set at the home page 20 | GAMES_PER_SET = 5 21 | -------------------------------------------------------------------------------- /common/management/commands/delete_job.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | import pprint 3 | from redis import Redis 4 | from rq.job import Job 5 | from rq import Queue 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Delete a job" 10 | 11 | def add_arguments(self, parser): 12 | parser.add_argument("job_id", type=str, help="Job ID") 13 | 14 | def handle(self, *args, **options): 15 | redis = Redis() 16 | job_id = str(options["job_id"]) 17 | job = Job.fetch(job_id, connection=redis) 18 | job.delete() 19 | self.stdout.write(self.style.SUCCESS(f"Deleted {job}")) 20 | -------------------------------------------------------------------------------- /common/management/commands/list_jobs.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | import pprint 3 | from redis import Redis 4 | from rq.job import Job 5 | from rq import Queue 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Show jobs in queue" 10 | 11 | def add_arguments(self, parser): 12 | parser.add_argument("queue", type=str, help="Queue") 13 | 14 | def handle(self, *args, **options): 15 | redis = Redis() 16 | queue = Queue(str(options["queue"]), connection=redis) 17 | for registry in [ 18 | queue.started_job_registry, 19 | queue.deferred_job_registry, 20 | queue.finished_job_registry, 21 | queue.failed_job_registry, 22 | queue.scheduled_job_registry, 23 | ]: 24 | self.stdout.write(self.style.SUCCESS(f"Registry {registry}")) 25 | for job_id in registry.get_job_ids(): 26 | try: 27 | job = Job.fetch(job_id, connection=redis) 28 | pprint.pp(job) 29 | except Exception as e: 30 | print(f"Error fetching {job_id}") 31 | -------------------------------------------------------------------------------- /common/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/common/models.py -------------------------------------------------------------------------------- /common/static/css/boofilsic_box.css: -------------------------------------------------------------------------------- 1 | .box { 2 | position: absolute; 3 | left: 50%; 4 | top: 50%; 5 | transform: translate(-50%, -50%); 6 | padding: 80px 100px; 7 | padding-bottom: 60px; 8 | background-color: var(--bright); 9 | text-align: center; 10 | min-width: 400px; 11 | } 12 | 13 | .box .sec-msg { 14 | color: var(--light); 15 | font-size: smaller; 16 | } 17 | 18 | .box .main-msg { 19 | margin-bottom: 5px; 20 | } 21 | 22 | .box .logo { 23 | width: 140px; 24 | margin-bottom: 60px; 25 | } 26 | 27 | .box p { 28 | text-align: justify; 29 | } 30 | 31 | /* body { 32 | filter: grayscale(1); 33 | } */ -------------------------------------------------------------------------------- /common/static/fonts/MaterialIcons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/common/static/fonts/MaterialIcons.woff2 -------------------------------------------------------------------------------- /common/static/img/fediverse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /common/static/img/logo_blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/common/static/img/logo_blue.png -------------------------------------------------------------------------------- /common/static/img/logo_square.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/common/static/img/logo_square.jpg -------------------------------------------------------------------------------- /common/static/img/logo_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/common/static/img/logo_square.png -------------------------------------------------------------------------------- /common/static/js/create_update_review.js: -------------------------------------------------------------------------------- 1 | $(document).ready( function() { 2 | 3 | $(".markdownx-preview").hide(); 4 | $(".markdownx textarea").attr("placeholder", "从剪贴板粘贴或者拖拽文件至编辑框即可插入图片"); 5 | 6 | $(".review-form__preview-button").on('click', function() { 7 | if ($(".markdownx-preview").is(":visible")) { 8 | $(".review-form__preview-button").text("预览"); 9 | $(".markdownx-preview").hide(); 10 | $(".markdownx textarea").show(); 11 | } else { 12 | $(".review-form__preview-button").text("编辑"); 13 | $(".markdownx-preview").show(); 14 | $(".markdownx textarea").hide(); 15 | } 16 | }); 17 | 18 | let ratingLabels = $(".rating-star"); 19 | $(ratingLabels).each( function(index, value) { 20 | let ratingScore = $(this).data("rating-score") / 2; 21 | $(this).starRating({ 22 | initialRating: ratingScore, 23 | readOnly: true, 24 | }); 25 | }); 26 | 27 | }); -------------------------------------------------------------------------------- /common/static/js/key_value_input.js: -------------------------------------------------------------------------------- 1 | function keyValueInput(valueKeyWidget, hiddenInput) { 2 | let placeholderKey = valueKeyWidget.attr('placeholder-key'); 3 | let placeholderValue = valueKeyWidget.attr('placeholder-value'); 4 | if (placeholderKey == null) { 5 | placeholderKey = ''; 6 | } 7 | 8 | if (placeholderValue == null) { 9 | placeholderValue = ''; 10 | } 11 | // assign existing pairs to hidden input 12 | setHiddenInput(valueKeyWidget); 13 | 14 | let newInputPair = $(''); 15 | valueKeyWidget.append(newInputPair.clone()); 16 | // add new input pair 17 | valueKeyWidget.on('input', ':nth-last-child(1)', function () { 18 | if ($(this).val() && $(this).prev().val()) { 19 | valueKeyWidget.append($(newInputPair).clone()); 20 | } 21 | }); 22 | valueKeyWidget.on('input', ':nth-last-child(2)', function () { 23 | if ($(this).val() && $(this).next().val()) { 24 | valueKeyWidget.append($(newInputPair).clone()); 25 | } 26 | }); 27 | valueKeyWidget.on('input', ':nth-last-child(4)', function () { 28 | if (!$(this).val() && !$(this).next().val() && valueKeyWidget.children("input").length > 2) { 29 | $(this).next().remove(); 30 | $(this).remove(); 31 | } 32 | }); 33 | 34 | valueKeyWidget.on('input', ':nth-last-child(3)', function () { 35 | if (!$(this).val() && !$(this).prev().val() && valueKeyWidget.children("input").length > 2) { 36 | $(this).prev().remove(); 37 | $(this).remove(); 38 | } 39 | }); 40 | 41 | valueKeyWidget.on('input', function () { 42 | setHiddenInput(this); 43 | }); 44 | 45 | function setHiddenInput(elem) { 46 | let keys = $(elem).children(":nth-child(odd)").map(function () { 47 | if ($(this).val()) { 48 | return $(this).val(); 49 | } 50 | }).get(); 51 | let values = $(elem).children(":nth-child(even)").map(function () { 52 | if ($(this).val()) { 53 | return $(this).val(); 54 | } 55 | }).get(); 56 | if (keys.length == values.length) { 57 | let finalValue = []; 58 | keys.forEach(function (key, i) { 59 | let json = new Object; 60 | json[key] = values[i]; 61 | finalValue.push(JSON.stringify(json)) 62 | }); 63 | hiddenInput.val(finalValue.toString()); 64 | } else if (keys.length - values.length == 1) { 65 | let finalValue = []; 66 | keys.forEach(function (key, i) { 67 | let json = new Object; 68 | if (i < keys.length - 1) { 69 | json[key] = values[i]; 70 | } else { 71 | json[key] = '' 72 | } 73 | finalValue.push(JSON.stringify(json)) 74 | }); 75 | hiddenInput.val(finalValue.toString()); 76 | } 77 | } 78 | 79 | 80 | } 81 | 82 | -------------------------------------------------------------------------------- /common/static/js/rating-star-readonly.js: -------------------------------------------------------------------------------- 1 | $(document).ready( function() { 2 | let render = function() { 3 | let ratingLabels = $(".rating-star"); 4 | $(ratingLabels).each( function(index, value) { 5 | let ratingScore = $(this).data("rating-score") / 2; 6 | $(this).starRating({ 7 | initialRating: ratingScore, 8 | readOnly: true 9 | }); 10 | }); 11 | }; 12 | document.body.addEventListener('htmx:load', function(evt) { 13 | render(); 14 | }); 15 | render(); 16 | }); -------------------------------------------------------------------------------- /common/static/js/scrape.js: -------------------------------------------------------------------------------- 1 | $(document).ready( function() { 2 | 3 | $(".submit").on('click', function(e) { 4 | e.preventDefault(); 5 | let form = $("#scrapeForm form"); 6 | if (form.data('submitted') === true) { 7 | // Previously submitted - don't submit again 8 | } else { 9 | // Mark it so that the next submit can be ignored 10 | form.data('submitted', true); 11 | $("#scrapeForm form").submit(); 12 | } 13 | }); 14 | 15 | // assume there is only one input[file] on page 16 | // $("input[type='file']").each(function() { 17 | // $(this).after(''); 18 | // }); 19 | 20 | // preview uploaded pic 21 | $("input[type='file']").change(function() { 22 | if (this.files && this.files[0]) { 23 | var reader = new FileReader(); 24 | 25 | reader.onload = function (e) { 26 | $('#previewImage').attr('src', e.target.result); 27 | } 28 | 29 | reader.readAsDataURL(this.files[0]); 30 | } 31 | }); 32 | 33 | $("#parser textarea").on('paste', function(e) { 34 | 35 | // access the clipboard using the api 36 | let pastedData = e.originalEvent.clipboardData.getData('text'); 37 | let lines = pastedData.split('\n') 38 | lines.forEach(line => { 39 | words = line.split(': '); 40 | if (words.length > 1) { 41 | switch (words[0]) { 42 | case '作者': 43 | authors = words[1].replace(' / ', ','); 44 | $("input[name='author']").val(authors); 45 | break; 46 | case '译者': 47 | translators = words[1].replace(' / ', ','); 48 | $("input[name='translator']").val(translators); 49 | break; 50 | case '出版社': 51 | $("input[name='pub_house']").val(words[1]); 52 | break; 53 | case '页数': 54 | let tmp = Number(words[1]); 55 | $("input[name='pages']").val(tmp); 56 | break; 57 | case '出版年': 58 | let regex = /\d+\d*/g; 59 | let figures = words[1].match(regex) 60 | figures.forEach(figure => { 61 | if (figure > 1000) $("input[name='pub_year']").val(figure); 62 | else if (figure < 13) $("input[name='pub_month']").val(figure); 63 | }); 64 | break; 65 | case '定价': 66 | $("input[name='price']").val(words[1]); 67 | break; 68 | case '装帧': 69 | $("input[name='binding']").val(words[1]); 70 | break; 71 | case 'ISBN': 72 | $("input[name='isbn']").val(words[1]); 73 | break; 74 | case '副标题': 75 | $("input[name='subtitle']").val(words[1]); 76 | break; 77 | case '原作名': 78 | $("input[name='orig_title']").val(words[1]); 79 | break; 80 | case '语言': 81 | $("input[name='language']").val(words[1]); 82 | break; 83 | default: 84 | $(".widget-value-key-input :nth-last-child(2)").val(words[0]); 85 | $(".widget-value-key-input :nth-last-child(1)").val(words[1]); 86 | $(".widget-value-key-input :nth-last-child(1)").trigger("input"); 87 | break; 88 | } 89 | } 90 | }); 91 | }); 92 | 93 | }); -------------------------------------------------------------------------------- /common/static/opensearch.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | NeoDB 4 | 输入关键字或站外条目链接,搜索NeoDB书影音游戏 5 | UTF-8 6 | https://neodb.social/static/img/logo-square.jpg 7 | 8 | 9 | -------------------------------------------------------------------------------- /common/static/react/editor.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:Material Icons;font-style:normal;font-weight:400;src:url(/static/fonts/MaterialIcons.woff2) format("woff2")}#editor-wrapper{background-color:#f8f8f8;width:68%;min-width:68%;padding:1.2rem 2.4rem;min-height:90vh}@media screen and (max-width: 720px){#editor-wrapper{width:100%;padding:0}}.editor{border-radius:2px;padding:.8rem;height:100%}.editor .material-icons{display:block;font-size:1.6rem;text-align:center;font-family:Material Icons;letter-spacing:normal;white-space:nowrap;-webkit-font-smoothing:antialiased}@media screen and (max-width: 540px){.editor .material-icons{font-size:1.4rem}}.editor-header{position:sticky;top:-.6rem;background-color:#f8f8f8cc;z-index:9}.editor-action{display:flex}.editor-action input{flex:1 0;outline:none;border:none;font-size:1.5rem;color:#3c3c3c;background-color:inherit}@media screen and (max-width: 540px){.editor-action input{font-size:1.2rem}}.editor-action button{float:right}.editor-toolbar{margin-top:.4rem;background-color:inherit;display:grid;grid-auto-flow:column;overflow-x:auto;width:100%}@media (min-width: 960px){.editor-toolbar{width:90%}}@media (min-width: 1200px){.editor-toolbar{width:75%}}.editor-toolbar modal.link-opened,.editor-toolbar modal.image-opened{position:absolute;top:120px;left:5%;width:90%;height:110%;background-color:#3c3c3c66;z-index:10;padding:1rem;border-radius:6px;backdrop-filter:blur(1px)}.editor-toolbar modal.link-opened input,.editor-toolbar modal.image-opened input{font-size:1rem;padding:.5rem;margin-bottom:.6rem;width:100%;border-radius:.375rem;border:1px #fffff8 dashed;background-color:transparent;color:#fffff8}.editor-toolbar modal.link-opened input::placeholder,.editor-toolbar modal.image-opened input::placeholder{opacity:.9;color:#fffff8}.editor-toolbar modal.link-opened div,.editor-toolbar modal.image-opened div{display:flex;justify-content:flex-end;align-items:center;gap:.5rem}.editor-toolbar modal.link-opened div [type=button],.editor-toolbar modal.image-opened div [type=button]{background-color:#a493da;color:#fff;border-radius:.375rem;border:none;padding:.1rem .4rem}.editor-toolbar modal.link-opened div [type=button] :hover,.editor-toolbar modal.image-opened div [type=button] :hover{background-color:#8671b5}.editor-content{font-size:.9rem;letter-spacing:.8px;line-height:1.6}.editor-content img{cursor:grab;margin:auto;max-width:100%;min-width:100px;min-height:100px}.editor-content img+.editor-button{align-self:end}.editor-button{color:#d1d5db;cursor:pointer}@media (hover: hover){.editor-button:hover{color:#6b7280}}.editor-button.active{color:#6b7280} 2 | -------------------------------------------------------------------------------- /common/static/sass/_Blockquote.sass: -------------------------------------------------------------------------------- 1 | 2 | // Blockquote 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | blockquote 6 | border-left: .3rem solid $color-quaternary 7 | margin-left: 0 8 | margin-right: 0 9 | padding: 1rem 1.5rem 10 | 11 | *:last-child 12 | margin-bottom: 0 13 | -------------------------------------------------------------------------------- /common/static/sass/_Button.sass: -------------------------------------------------------------------------------- 1 | 2 | // Button 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | .button, 6 | button, 7 | input[type='button'], 8 | input[type='reset'], 9 | input[type='submit'] 10 | background-color: $color-primary 11 | border: .1rem solid $color-primary 12 | border-radius: .4rem 13 | color: $color-initial 14 | cursor: pointer 15 | display: inline-block 16 | font-size: 1.1rem 17 | font-weight: 700 18 | height: 3.4rem 19 | letter-spacing: .1rem 20 | line-height: 3.4rem 21 | padding: 0 2.8rem 22 | // width: 100% 23 | text-align: center 24 | text-decoration: none 25 | text-transform: uppercase 26 | white-space: nowrap 27 | 28 | &:focus, 29 | &:hover 30 | background-color: $color-secondary 31 | border-color: $color-secondary 32 | color: $color-initial 33 | outline: 0 34 | 35 | &[disabled] 36 | cursor: default 37 | opacity: .5 38 | 39 | &:focus, 40 | &:hover 41 | background-color: $color-primary 42 | border-color: $color-primary 43 | 44 | &.button-outline 45 | background-color: transparent 46 | color: $color-primary 47 | 48 | &:focus, 49 | &:hover 50 | background-color: transparent 51 | border-color: $color-secondary 52 | color: $color-secondary 53 | 54 | &[disabled] 55 | 56 | &:focus, 57 | &:hover 58 | border-color: inherit 59 | color: $color-primary 60 | 61 | &.button-clear 62 | background-color: transparent 63 | border-color: transparent 64 | color: $color-primary 65 | 66 | &:focus, 67 | &:hover 68 | background-color: transparent 69 | border-color: transparent 70 | color: $color-secondary 71 | 72 | &[disabled] 73 | 74 | &:focus, 75 | &:hover 76 | color: $color-primary 77 | -------------------------------------------------------------------------------- /common/static/sass/_Code.sass: -------------------------------------------------------------------------------- 1 | 2 | // Code 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | code 6 | background: $color-tertiary 7 | border-radius: .4rem 8 | font-size: 86% 9 | margin: 0 .2rem 10 | padding: .2rem .5rem 11 | white-space: nowrap 12 | 13 | pre 14 | background: $color-tertiary 15 | border-left: .3rem solid $color-primary 16 | overflow-y: hidden 17 | 18 | & > code 19 | border-radius: 0 20 | display: block 21 | padding: 1rem 1.5rem 22 | white-space: pre 23 | -------------------------------------------------------------------------------- /common/static/sass/_Color.sass: -------------------------------------------------------------------------------- 1 | 2 | // Color 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | $color-initial: #fff !default 6 | $color-primary: #00a1cc !default 7 | $color-secondary: #606c76 !default 8 | $color-tertiary: #bbb !default 9 | $color-quaternary: #d5d5d5 !default 10 | $color-quinary: #e5e5e5 !default 11 | 12 | $color-light: #ccc 13 | $color-bright: rgb(247, 247, 247) 14 | -------------------------------------------------------------------------------- /common/static/sass/_Divider.sass: -------------------------------------------------------------------------------- 1 | 2 | // Divider 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | hr 6 | border: 0 7 | border-top: .1rem solid $color-initial 8 | margin: 3.0rem 0 9 | -------------------------------------------------------------------------------- /common/static/sass/_Footer.sass: -------------------------------------------------------------------------------- 1 | // these 2 id selectors are to make footer stay at the bottom 2 | #page-wrapper 3 | position: relative 4 | min-height: 100vh 5 | z-index: 0 6 | 7 | #content-wrapper 8 | padding-bottom: 160px 9 | // min-height: 100vh; 10 | 11 | .footer 12 | padding-top: 0.4em !important 13 | text-align: center 14 | margin-bottom: 4px !important 15 | position: absolute !important 16 | left: 50% 17 | transform: translateX(-50%) 18 | bottom: 0 19 | width: 100% 20 | 21 | 22 | &__border 23 | // width: 100% 24 | padding-top: 4px; 25 | border-top: $color-bright solid 2px 26 | 27 | &__link 28 | margin: 0 12px 29 | white-space: nowrap 30 | 31 | // Small devices (landscape phones, 576px and up) 32 | @media (max-width: $small-devices) 33 | #content-wrapper 34 | padding-bottom: 120px 35 | // Medium devices (tablets, 768px and up) 36 | @media (max-width: $medium-devices) 37 | pass 38 | // Large devices (desktops, 992px and up) 39 | @media (max-width: $large-devices) 40 | pass 41 | // Extra large devices (large desktops, 1200px and up) 42 | @media (max-width: $x-large-devices) 43 | pass 44 | -------------------------------------------------------------------------------- /common/static/sass/_Form.sass: -------------------------------------------------------------------------------- 1 | 2 | // Form 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | 6 | 7 | select 8 | background: url('data:image/svg+xml;utf8,') center right no-repeat 9 | padding-right: 3.0rem 10 | 11 | &:focus 12 | background-image: url('data:image/svg+xml;utf8,') 13 | 14 | textarea 15 | min-height: 6.5rem 16 | width: 100% 17 | 18 | select 19 | width: 100% 20 | 21 | label, 22 | legend 23 | display: block 24 | // font-size: 1.6rem 25 | // font-weight: 700 26 | margin-bottom: .5rem 27 | 28 | fieldset 29 | border-width: 0 30 | padding: 0 31 | 32 | input[type='checkbox'], 33 | input[type='radio'] 34 | display: inline 35 | 36 | .label-inline 37 | display: inline-block 38 | font-weight: normal 39 | margin-left: .5rem 40 | -------------------------------------------------------------------------------- /common/static/sass/_Global.sass: -------------------------------------------------------------------------------- 1 | 2 | // Base 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | // Set box-sizing globally to handle padding and border widths 6 | \:root 7 | font-size: 10px 8 | 9 | *, 10 | *:after, 11 | *:before 12 | box-sizing: inherit 13 | 14 | // The base font-size is set at 62.5% for having the convenience 15 | // of sizing rems in a way that is similar to using px: 1.6rem = 16px 16 | html 17 | box-sizing: border-box 18 | // font-size: 62.5% 19 | height: 100% 20 | 21 | // Default body styles 22 | body 23 | color: $color-secondary 24 | font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'Microsoft YaHei Light', sans-serif 25 | font-size: 1.3rem 26 | font-weight: 300 27 | letter-spacing: .05rem 28 | line-height: 1.6 29 | margin: 0 30 | height: 100% 31 | // filter: grayscale(1) 32 | 33 | textarea 34 | font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'Microsoft YaHei Light', sans-serif 35 | 36 | // default link styles 37 | a 38 | color: $color-primary 39 | text-decoration: none 40 | 41 | &:active, 42 | &:hover, 43 | &:hover:visited 44 | color: $color-secondary 45 | 46 | &:visited 47 | // color: $color-primary 48 | 49 | li 50 | list-style: none 51 | 52 | // clear the "x" button inside the search input box 53 | input[type=text]::-ms-clear, 54 | input[type=text]::-ms-reveal 55 | display: none 56 | width : 0 57 | height: 0 58 | 59 | input[type="search"]::-webkit-search-decoration, 60 | input[type="search"]::-webkit-search-cancel-button, 61 | input[type="search"]::-webkit-search-results-button, 62 | input[type="search"]::-webkit-search-results-decoration 63 | display: none; 64 | 65 | input[type='email'], 66 | input[type='number'], 67 | input[type='password'], 68 | input[type='search'], 69 | input[type='tel'], 70 | input[type='text'], 71 | input[type='url'], 72 | input[type='date'], 73 | input[type='time'], 74 | input[type='color'], 75 | textarea, 76 | select 77 | appearance: none // Removes awkward default styles on some inputs for iOS 78 | background-color: transparent 79 | border: .1rem solid $color-light 80 | border-radius: .4rem 81 | box-shadow: none 82 | box-sizing: inherit // Forced to replace inherit values of the normalize.css 83 | padding: .6rem 1.0rem // The .6rem vertically centers text on FF, ignored by Webkit 84 | 85 | &:focus 86 | border-color: $color-primary 87 | outline: 0 88 | 89 | &::placeholder 90 | color: $color-light 91 | 92 | ::selection 93 | color: white 94 | background-color: $color-primary 95 | 96 | // Mixins 97 | 98 | @mixin clear 99 | content: ' ' 100 | clear: both 101 | display: table 102 | 103 | // Breakpoints 104 | // Small devices (landscape phones, 576px and up) 105 | $small-devices: 575.98px 106 | // Medium devices (tablets, 768px and up) 107 | $medium-devices: 767.98px 108 | // Large devices (desktops, 992px and up) 109 | $large-devices: 991.98px 110 | // Extra large devices (large desktops, 1200px and up) 111 | $x-large-devices: 1199.98px 112 | -------------------------------------------------------------------------------- /common/static/sass/_Grid.sass: -------------------------------------------------------------------------------- 1 | 2 | // Grid 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | @mixin container 5 | margin: 0 auto 6 | position: relative 7 | max-width: 110rem 8 | padding: 0 2.0rem; 9 | width: 100% 10 | 11 | .grid 12 | @include container 13 | 14 | $main-width: 70% 15 | $aside-width: 96% - $main-width 16 | 17 | & &__main 18 | width: $main-width 19 | float: left 20 | position: relative 21 | 22 | & &__aside 23 | width: $aside-width 24 | // background-color: $color-bright 25 | float: right 26 | position: relative 27 | 28 | display: flex 29 | flex-direction: column 30 | justify-content: space-around 31 | 32 | &::after 33 | @include clear 34 | 35 | // Small devices (landscape phones, 576px and up) 36 | @media (max-width: $small-devices) 37 | .grid 38 | & &__aside 39 | flex-direction: column !important 40 | // Medium devices (tablets, 768px and up) 41 | @media (max-width: $medium-devices) 42 | pass 43 | // Large devices (desktops, 992px and up) 44 | @media (max-width: $large-devices) 45 | .grid 46 | & &__main 47 | width: 100% 48 | float: none 49 | 50 | & &__aside 51 | width: 100% 52 | float: none 53 | flex-direction: row 54 | &--tablet-column 55 | flex-direction: column 56 | &--reverse-order 57 | transform: scaleY(-1) 58 | & &__main--reverse-order 59 | transform: scaleY(-1) 60 | & &__aside--reverse-order 61 | transform: scaleY(-1) 62 | // Extra large devices (large desktops, 1200px and up) 63 | @media (max-width: $x-large-devices) 64 | pass 65 | 66 | 67 | -------------------------------------------------------------------------------- /common/static/sass/_Icon.sass: -------------------------------------------------------------------------------- 1 | .icon-lock svg 2 | fill: $color-light 3 | height: 12px 4 | position: relative 5 | top: 1px 6 | margin-left: 3px 7 | 8 | .icon-edit svg 9 | fill: $color-light 10 | height: 12px 11 | position: relative 12 | top: 2px 13 | 14 | .icon-save svg 15 | fill: $color-light 16 | height: 12px 17 | position: relative 18 | top: 2px 19 | 20 | .icon-cross svg 21 | fill: $color-light 22 | height: 10px 23 | position: relative 24 | 25 | .icon-arrow svg 26 | fill: $color-secondary 27 | height: 15px 28 | position: relative 29 | top: 3px 30 | 31 | 32 | .spinner 33 | display: inline-block 34 | position: relative 35 | left: 50% 36 | transform: translateX(-50%) scale(0.4) 37 | width: 80px 38 | height: 80px 39 | 40 | & div 41 | transform-origin: 40px 40px 42 | animation: spinner 1.2s linear infinite 43 | &::after 44 | content: " "; 45 | display: block; 46 | position: absolute; 47 | top: 3px; 48 | left: 37px; 49 | width: 6px; 50 | height: 18px; 51 | border-radius: 20%; 52 | background: $color-secondary 53 | @for $i from 1 through 12 54 | &:nth-child(#{$i}) 55 | transform: rotate(($i - 1) * 30deg) 56 | animation-delay: (12 - $i) * -0.1s 57 | 58 | @keyframes spinner 59 | 0% 60 | opacity: 1 61 | 100% 62 | opacity: 0 63 | -------------------------------------------------------------------------------- /common/static/sass/_Image.sass: -------------------------------------------------------------------------------- 1 | 2 | // Image 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | img 6 | max-width: 100% 7 | object-fit: contain 8 | 9 | &.emoji 10 | height: 14px 11 | box-sizing: border-box 12 | object-fit: contain 13 | position: relative 14 | top: 3px 15 | 16 | &--large 17 | height: 20px 18 | position: relative 19 | top: 2px 20 | -------------------------------------------------------------------------------- /common/static/sass/_Label.sass: -------------------------------------------------------------------------------- 1 | // source label name should match the enum value in `common.models.SourceSiteEnum` 2 | 3 | $in-site-color: $color-primary 4 | $douban-color-primary: #319840 5 | $douban-color-secondary: white 6 | $spotify-color-primary: #1ed760 7 | $spotify-color-secondary: black 8 | $imdb-color-primary: #F5C518 9 | $imdb-color-secondary: #121212 10 | $igdb-color-primary: #323A44 11 | $igdb-color-secondary: #DFE1E2 12 | $steam-color-primary: #1387b8 13 | $steam-color-secondary: #111d2e 14 | $bangumi-color-primary: #F09199 15 | $bangumi-color-secondary: #FCFCFC 16 | $goodreads-color-primary: #372213 17 | $goodreads-color-secondary: #F4F1EA 18 | $tmdb-color-primary: #91CCA3 19 | $tmdb-color-secondary: #1FB4E2 20 | $bandcamp-color-primary: #28A0C1 21 | $bandcamp-color-secondary: white 22 | 23 | .source-label 24 | display: inline 25 | background: transparent 26 | border-radius: .3rem 27 | border-style: solid 28 | border-width: .1rem 29 | line-height: 1.2rem 30 | font-size: 1.1rem 31 | margin: 3px 32 | padding: 1px 3px 33 | padding-top: 2px 34 | font-weight: lighter 35 | letter-spacing: 0.1rem 36 | word-break: keep-all 37 | opacity: 1 38 | 39 | 40 | position: relative 41 | top: -1px 42 | 43 | &.source-label__in-site 44 | border-color: $in-site-color 45 | color: $in-site-color 46 | &.source-label__douban 47 | border: none 48 | color: $douban-color-secondary 49 | background-color: $douban-color-primary 50 | &.source-label__amazon 51 | &.source-label__spotify 52 | background-color: $spotify-color-primary 53 | color: $spotify-color-secondary 54 | border: none 55 | font-weight: bold 56 | &.source-label__imdb 57 | background-color: $imdb-color-primary 58 | color: $imdb-color-secondary 59 | border: none 60 | font-weight: bold 61 | &.source-label__igdb 62 | background-color: $igdb-color-primary 63 | color: $igdb-color-secondary 64 | border: none 65 | font-weight: bold 66 | &.source-label__steam 67 | background: linear-gradient(30deg, $steam-color-primary, $steam-color-secondary) 68 | color: white 69 | border: none 70 | font-weight: 600 71 | padding-top: 2px 72 | &.source-label__bangumi 73 | background: $bangumi-color-secondary 74 | color: $bangumi-color-primary 75 | font-style: italic 76 | font-weight: 600 77 | &.source-label__goodreads 78 | background: $goodreads-color-secondary 79 | color: $goodreads-color-primary 80 | font-weight: lighter 81 | &.source-label__tmdb 82 | background: linear-gradient(90deg, $tmdb-color-primary, $tmdb-color-secondary) 83 | color: white 84 | border: none 85 | font-weight: lighter 86 | padding-top: 2px 87 | &.source-label__googlebooks 88 | color: white 89 | background-color: #4285F4 90 | border-color: #4285F4 91 | &.source-label__bandcamp 92 | color: $bandcamp-color-secondary 93 | background-color: $bandcamp-color-primary 94 | // transform: skewX(-30deg) 95 | display: inline-block 96 | &.source-label__bandcamp span 97 | // transform: skewX(30deg) 98 | display: inline-block 99 | margin: 0 4px 100 | -------------------------------------------------------------------------------- /common/static/sass/_List.sass: -------------------------------------------------------------------------------- 1 | 2 | // List 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | dl, 6 | ol, 7 | ul 8 | list-style: none 9 | margin-top: 0 10 | padding-left: 0 11 | 12 | dl, 13 | ol, 14 | ul 15 | // font-size: 90% 16 | // margin: 1.5rem 0 1.5rem 3.0rem 17 | 18 | ol 19 | list-style: decimal inside 20 | 21 | ul 22 | list-style: circle inside 23 | -------------------------------------------------------------------------------- /common/static/sass/_Modal.sass: -------------------------------------------------------------------------------- 1 | .bg-mask 2 | background-color: black 3 | z-index: 1 4 | filter: opacity(20%) 5 | position: fixed 6 | width: 100% 7 | height: 100% 8 | left: 0 9 | top: 0 10 | display: none 11 | 12 | @mixin modal 13 | z-index: 2 14 | display: none 15 | position: fixed 16 | width: 500px 17 | max-width: 100vw 18 | top: 50% 19 | left: 50% 20 | transform: translate(-50%, -50%) 21 | background-color: $color-bright 22 | padding: 20px 20px 10px 20px 23 | color: $color-secondary 24 | & &__head 25 | margin-bottom: 20px 26 | &::after 27 | @include clear 28 | 29 | & &__title 30 | font-weight: bold 31 | font-size: 1.2em 32 | float: left 33 | 34 | & &__close-button 35 | float: right 36 | cursor: pointer 37 | 38 | & &__body 39 | 40 | & &__confirm-button 41 | float: right 42 | 43 | .mark-modal 44 | @include modal 45 | & input[type="radio"] 46 | margin-right: 0 47 | 48 | & &__rating-star 49 | display: inline 50 | float: left 51 | position: relative 52 | left: -3px 53 | 54 | & &__status-radio 55 | float: right 56 | & ul 57 | margin-bottom: 0 58 | & li, & label 59 | display: inline 60 | & input[type="radio"] 61 | position: relative 62 | top: 1px 63 | 64 | & &__clear 65 | @include clear 66 | 67 | & &__content-input, & form textarea 68 | height: 200px 69 | width: 100% 70 | margin-top: 5px 71 | margin-bottom: 5px 72 | resize: vertical 73 | 74 | & &__tag 75 | // margin-top: 10px 76 | margin-bottom: 20px 77 | 78 | & &__option 79 | margin-bottom: 24px 80 | &::after 81 | @include clear 82 | 83 | & &__visibility-radio 84 | float: left 85 | & ul, & li, & label 86 | display: inline 87 | & label 88 | font-size: normal 89 | & input[type="radio"] 90 | position: relative 91 | top: 2px 92 | 93 | & &__share-checkbox 94 | float: right 95 | & input[type="checkbox"] 96 | position: relative 97 | top: 2px 98 | 99 | .confirm-modal 100 | @include modal 101 | 102 | .announcement-modal 103 | @include modal 104 | 105 | & &__body 106 | overflow-y: auto 107 | max-height: 64vh 108 | & .announcement 109 | &__title 110 | display: inline-block 111 | 112 | &__datetime 113 | color: $color-light 114 | margin-left: 10px 115 | 116 | &__content 117 | word-break: break-all 118 | 119 | .add-to-list-modal 120 | @include modal 121 | 122 | // Small devices (landscape phones, 576px and up) 123 | @media (max-width: $small-devices) 124 | .mark-modal, .confirm-modal, .announcement-modal .add-to-list-modal 125 | width: 100% 126 | // Medium devices (tablets, 768px and up) 127 | @media (max-width: $medium-devices) 128 | pass 129 | // Large devices (desktops, 992px and up) 130 | @media (max-width: $large-devices) 131 | pass 132 | // Extra large devices (large desktops, 1200px and up) 133 | @media (max-width: $x-large-devices) 134 | pass -------------------------------------------------------------------------------- /common/static/sass/_Navbar.sass: -------------------------------------------------------------------------------- 1 | 2 | .navbar 3 | background-color: $color-bright 4 | box-sizing: border-box 5 | padding: 10px 0 6 | margin-bottom: 50px 7 | border-bottom: $color-light 0.5px solid 8 | 9 | & &__wrapper 10 | display: flex 11 | justify-content: space-between 12 | align-items: center 13 | position: relative 14 | 15 | & &__logo 16 | flex-basis: 100px 17 | 18 | & &__logo-link 19 | display: inline-block 20 | 21 | & &__link-list 22 | margin: 0 23 | display: flex 24 | justify-content: space-around 25 | 26 | & &__link 27 | margin: 9px 28 | color: $color-secondary 29 | 30 | &:active, 31 | &:hover, 32 | &:hover:visited 33 | color: $color-primary 34 | 35 | &:visited 36 | color: $color-secondary 37 | 38 | .current 39 | color: $color-primary 40 | font-weight: bold 41 | 42 | & &__search-box 43 | margin: 0 12% 0 15px 44 | display: inline-flex 45 | flex: 1 46 | $widget-height: 32px 47 | & > input[type="search"] 48 | border-top-right-radius: 0 49 | border-bottom-right-radius: 0 50 | margin: 0 51 | height: $widget-height 52 | background-color: white !important 53 | width: 100% 54 | 55 | & .navbar__search-dropdown 56 | margin: 0 57 | margin-left: -1px 58 | padding: 0 59 | padding-left: 10px 60 | color: $color-secondary 61 | appearance: auto 62 | background-color: white 63 | height: $widget-height 64 | width: 80px 65 | border-top-left-radius: 0 66 | border-bottom-left-radius: 0 67 | 68 | & &__dropdown-btn 69 | display: none 70 | padding: 0 71 | margin: 0 72 | border: none 73 | 74 | background-color: transparent 75 | color: $color-primary 76 | &:focus, 77 | &:hover 78 | background-color: transparent 79 | color: $color-secondary 80 | 81 | // Small devices (landscape phones, 576px and up) 82 | @media (max-width: $small-devices) 83 | .navbar 84 | padding: 2px 0 85 | & &__wrapper 86 | display: block 87 | & &__logo-img 88 | width: 72px 89 | margin-right: 10px 90 | position: relative 91 | top: 7px 92 | 93 | // dropdown 94 | & &__link-list 95 | // display: block 96 | margin-top: 7px 97 | max-height: 0 98 | transition: max-height 0.6s ease-out 99 | overflow: hidden; 100 | & &__dropdown-btn 101 | display: block 102 | position: absolute 103 | right: 5px 104 | top: 3px 105 | 106 | transform: scale(0.7) 107 | &:hover + .navbar__link-list 108 | max-height: 500px 109 | transition: max-height 0.6s ease-in 110 | 111 | & &__search-box 112 | margin: 0 113 | width: 46vw 114 | $widget-height: 26px 115 | & > input[type="search"] 116 | height: $widget-height 117 | padding: 4px 6px 118 | width: 32vw 119 | & .navbar__search-dropdown 120 | cursor: pointer 121 | height: $widget-height 122 | width: 80px 123 | padding-left: 5px 124 | // Medium devices (tablets, 768px and up) 125 | @media (max-width: $medium-devices) 126 | pass 127 | // Large devices (desktops, 992px and up) 128 | @media (max-width: $large-devices) 129 | .navbar 130 | margin-bottom: 20px 131 | // Extra large devices (large desktops, 1200px and up) 132 | @media (max-width: $x-large-devices) 133 | pass 134 | -------------------------------------------------------------------------------- /common/static/sass/_Pagination.sass: -------------------------------------------------------------------------------- 1 | .pagination 2 | // position: absolute 3 | // bottom: 30px 4 | // left: 50% 5 | // transform: translateX(-50%) 6 | text-align: center 7 | width: 100% 8 | 9 | & &__page-link 10 | font-weight: normal 11 | margin: 0 5px 12 | 13 | &--current 14 | font-weight: bold 15 | font-size: 1.2em 16 | // text-decoration: underline 17 | color: $color-secondary 18 | 19 | & &__nav-link 20 | font-size: 1.4em 21 | margin: 0 2px 22 | 23 | $nav-link-edge-margin-width: 18px 24 | 25 | &--right-margin 26 | margin-right: $nav-link-edge-margin-width 27 | 28 | &--left-margin 29 | margin-left: $nav-link-edge-margin-width 30 | 31 | &--hidden 32 | display: none 33 | 34 | 35 | // Small devices (landscape phones, 576px and up) 36 | @media (max-width: $small-devices) 37 | .pagination 38 | & &__page-link 39 | margin: 0 3px 40 | 41 | & &__nav-link 42 | font-size: 1.4em 43 | margin: 0 2px 44 | 45 | $nav-link-edge-margin-width: 10px 46 | 47 | &--right-margin 48 | margin-right: $nav-link-edge-margin-width 49 | 50 | &--left-margin 51 | margin-left: $nav-link-edge-margin-width 52 | // Medium devices (tablets, 768px and up) 53 | @media (max-width: $medium-devices) 54 | pass 55 | // Large devices (desktops, 992px and up) 56 | @media (max-width: $large-devices) 57 | pass 58 | // Extra large devices (large desktops, 1200px and up) 59 | @media (max-width: $x-large-devices) 60 | pass 61 | 62 | -------------------------------------------------------------------------------- /common/static/sass/_SingleSection.sass: -------------------------------------------------------------------------------- 1 | $single-section-padding: 32px 36px 2 | $single-section-padding-mobile: 32px 28px 3 | 4 | .single-section-wrapper 5 | padding: $single-section-padding 6 | background-color: $color-bright 7 | overflow: auto 8 | 9 | // & input, & select 10 | // width: 100% 11 | & &__link--secondary 12 | display: inline-block 13 | color: $color-light 14 | margin-bottom: 20px 15 | &:hover 16 | color: $color-primary 17 | 18 | .entity-form, .review-form 19 | overflow: auto 20 | & > input[type='email'], 21 | & > input[type='number'], 22 | & > input[type='password'], 23 | & > input[type='search'], 24 | & > input[type='tel'], 25 | & > input[type='text'], 26 | & > input[type='url'], 27 | & textarea 28 | width: 100% 29 | & img 30 | display: block 31 | 32 | .review-form 33 | & &__preview-button 34 | color: $color-primary 35 | font-weight: bold 36 | cursor: pointer 37 | 38 | & &__fyi 39 | color: $color-light 40 | 41 | & &__main-content, & textarea 42 | margin-bottom: 5px 43 | resize: vertical 44 | height: 400px 45 | 46 | & &__option 47 | margin-top: 24px 48 | margin-bottom: 10px 49 | &::after 50 | @include clear 51 | 52 | & &__visibility-radio 53 | float: left 54 | & ul, & li, & label 55 | display: inline 56 | & label 57 | font-size: normal 58 | & input[type="radio"] 59 | position: relative 60 | top: 2px 61 | 62 | & &__share-checkbox 63 | float: right 64 | & input[type="checkbox"] 65 | position: relative 66 | top: 2px 67 | 68 | 69 | .report-form 70 | & input, & select 71 | width: 100% 72 | // Small devices (landscape phones, 576px and up) 73 | @media (max-width: $small-devices) 74 | .review-form 75 | & &__visibility-radio 76 | float: unset 77 | & &__share-checkbox 78 | float: unset 79 | position: relative 80 | left: -3px -------------------------------------------------------------------------------- /common/static/sass/_Spacing.sass: -------------------------------------------------------------------------------- 1 | 2 | // Spacing 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | .button, 6 | button, 7 | dd, 8 | dt, 9 | li 10 | margin-bottom: 1.0rem 11 | 12 | fieldset, 13 | input, 14 | select, 15 | textarea 16 | margin-bottom: 1.5rem 17 | 18 | blockquote, 19 | dl, 20 | figure, 21 | form, 22 | ol, 23 | p, 24 | pre, 25 | table, 26 | ul 27 | margin-bottom: 1rem 28 | -------------------------------------------------------------------------------- /common/static/sass/_Table.sass: -------------------------------------------------------------------------------- 1 | 2 | // Table 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | table 6 | border-spacing: 0 7 | width: 100% 8 | 9 | td, 10 | th 11 | border-bottom: .1rem solid $color-quinary 12 | padding: 1.2rem 1.5rem 13 | text-align: left 14 | 15 | &:first-child 16 | padding-left: 0 17 | 18 | &:last-child 19 | padding-right: 0 20 | -------------------------------------------------------------------------------- /common/static/sass/_Typography.sass: -------------------------------------------------------------------------------- 1 | 2 | // Typography 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | b, 6 | strong 7 | font-weight: bold 8 | 9 | p 10 | margin-top: 0 11 | 12 | h1, 13 | h2, 14 | h3, 15 | h4, 16 | h5, 17 | h6 18 | font-weight: 300 19 | letter-spacing: -.1rem 20 | margin-bottom: 2.0rem 21 | margin-top: 0 22 | 23 | h1 24 | font-size: 4.6rem 25 | line-height: 1.2 26 | 27 | h2 28 | font-size: 3.6rem 29 | line-height: 1.25 30 | 31 | h3 32 | font-size: 2.8rem 33 | line-height: 1.3 34 | 35 | h4 36 | font-size: 2.2rem 37 | letter-spacing: -.08rem 38 | line-height: 1.35 39 | 40 | h5 41 | font-size: 1.8rem 42 | letter-spacing: -.05rem 43 | line-height: 1.5 44 | 45 | h6 46 | font-size: 1.6rem 47 | letter-spacing: 0 48 | line-height: 1.4 49 | -------------------------------------------------------------------------------- /common/static/sass/_Utility.sass: -------------------------------------------------------------------------------- 1 | 2 | // Utility 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | 5 | // Clear a float with .clearfix 6 | .clearfix 7 | 8 | &:after 9 | clear: both 10 | content: ' ' // The space content is one way to avoid an Opera bug. 11 | display: table 12 | 13 | // Float either direction 14 | .float-left 15 | float: left 16 | 17 | .float-right 18 | float: right 19 | 20 | .highlight 21 | font-weight: bold 22 | -------------------------------------------------------------------------------- /common/static/sass/_Vendor.sass: -------------------------------------------------------------------------------- 1 | .markdownx-preview 2 | min-height: 100px 3 | 4 | & ul li 5 | list-style: circle inside 6 | 7 | h1 8 | font-size: 2.5em 9 | 10 | h2 11 | font-size: 2.0em 12 | 13 | blockquote 14 | border-left: lightgray solid 0.4em 15 | padding-left: 0.1em 16 | margin-left: 0 17 | 18 | code 19 | border-left: $color-primary solid 0.3em 20 | padding-left: 0.1em 21 | 22 | .rating-star .jq-star 23 | cursor: unset !important 24 | 25 | .ms-parent > .ms-choice 26 | margin-bottom: 1.5rem 27 | appearance: none 28 | background-color: transparent 29 | border: 0.1rem solid #ccc 30 | border-radius: .4rem 31 | box-shadow: none 32 | box-sizing: inherit 33 | padding: .6rem 1.0rem 34 | width: 100% 35 | height: 30.126px 36 | 37 | &:focus 38 | border-color: $color-primary 39 | 40 | & > .icon-caret 41 | top: 15.5px 42 | 43 | & > span 44 | color: black 45 | font-weight: initial 46 | // font-size: 1.3rem 47 | font-size: 13.3333px 48 | top: 2.5px 49 | left: 2px 50 | &:hover, &:focus 51 | color: black 52 | 53 | .ms-parent > .ms-drop > ul > li > label 54 | & > span 55 | margin-left: 10px 56 | & > input 57 | width: unset 58 | 59 | .tippy-box 60 | border: $color-secondary 1px solid 61 | // border-radius: 2px 62 | background-color: $color-bright 63 | padding: 3px 5px 64 | 65 | .tippy-content 66 | 67 | .tag-input input 68 | flex-grow: 1 69 | 70 | .tools-section-wrapper input, .tools-section-wrapper select 71 | width: unset 72 | -------------------------------------------------------------------------------- /common/static/sass/boofilsic.sass: -------------------------------------------------------------------------------- 1 | 2 | // Sass Modules 3 | // –––––––––––––––––––––––––––––––––––––––––––––––––– 4 | @import url(https://cdn.jsdelivr.net/npm/skeleton-css@2.0.4/css/normalize.css) 5 | // milligram 6 | @import Color 7 | // @import Blockquote 8 | @import Button 9 | // @import Code 10 | // @import Divider 11 | @import Form 12 | @import List 13 | @import Spacing 14 | // @import Table 15 | @import Typography 16 | @import Image 17 | @import Utility 18 | 19 | // boofilsic components 20 | @import Global 21 | @import Navbar 22 | @import Grid 23 | @import Pagination 24 | @import Footer 25 | @import Icon 26 | @import Modal 27 | @import Label 28 | 29 | // boofilsic modules 30 | @import MainSection 31 | @import AsideSection 32 | @import SingleSection 33 | 34 | @import Vendor -------------------------------------------------------------------------------- /common/templates/common/error.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load static %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% trans '错误' %} 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 |
21 | {{ msg }} 22 |
23 |
24 | {% if secondary_msg %} 25 | {{ secondary_msg }} 26 | {% endif %} 27 |
28 | 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /common/templates/partial/_announcement.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load i18n %} 3 | {% load admin_url %} 4 | {% load mastodon %} 5 | {% load oauth_token %} 6 | {% load truncate %} 7 | {% load thumb %} 8 |
9 | 17 | 51 |
52 |
53 | 62 | -------------------------------------------------------------------------------- /common/templates/partial/_footer.html: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 22 | 29 | 30 | -------------------------------------------------------------------------------- /common/templates/partial/_navbar.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load i18n %} 3 | {% load admin_url %} 4 | -------------------------------------------------------------------------------- /common/templates/widgets/hstore.html: -------------------------------------------------------------------------------- 1 | 12 |
13 | {% if widget.value != None %} 14 | 15 | {% for pair in widget.value %} 16 | 17 | {% for k, v in pair.items %} 18 | 19 | {% endfor %} 20 | 21 | {% endfor %} 22 | 23 | {% endif %} 24 |
25 | 26 | -------------------------------------------------------------------------------- /common/templates/widgets/image.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /common/templates/widgets/multi_select.html: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /common/templates/widgets/tag.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /common/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/common/templatetags/__init__.py -------------------------------------------------------------------------------- /common/templatetags/admin_url.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.conf import settings 3 | from django.utils.html import format_html 4 | 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.simple_tag 10 | def admin_url(): 11 | url = settings.ADMIN_URL 12 | if not url.startswith("/"): 13 | url = "/" + url 14 | if not url.endswith("/"): 15 | url += "/" 16 | return format_html(url) 17 | -------------------------------------------------------------------------------- /common/templatetags/duration.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.template.defaultfilters import stringfilter 3 | from django.utils.text import Truncator 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.filter(is_safe=True) 9 | @stringfilter 10 | def duration_format(value): 11 | duration = int(value) 12 | h = duration // 3600000 13 | m = duration % 3600000 // 60000 14 | return (f"{h}小时 " if h else "") + (f"{m}分钟" if m else "") 15 | -------------------------------------------------------------------------------- /common/templatetags/highlight.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.safestring import mark_safe 3 | from django.template.defaultfilters import stringfilter 4 | from opencc import OpenCC 5 | 6 | 7 | cc = OpenCC("t2s") 8 | register = template.Library() 9 | 10 | 11 | @register.filter 12 | @stringfilter 13 | def highlight(text, search): 14 | for s in cc.convert(search.strip().lower()).split(" "): 15 | if s: 16 | p = cc.convert(text.lower()).find(s) 17 | if p != -1: 18 | text = f'{text[0:p]}{text[p:p+len(s)]}{text[p+len(s):]}' 19 | return mark_safe(text) 20 | -------------------------------------------------------------------------------- /common/templatetags/mastodon.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.conf import settings 3 | from django.template.defaultfilters import stringfilter 4 | 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.simple_tag 10 | def mastodon(domain): 11 | url = "https://" + domain 12 | return url 13 | 14 | 15 | @register.simple_tag(takes_context=True) 16 | def current_user_relationship(context, user): 17 | current_user = context["request"].user 18 | if current_user and current_user.is_authenticated: 19 | if current_user.is_following(user): 20 | if current_user.is_followed_by(user): 21 | return "互相关注" 22 | else: 23 | return "已关注" 24 | elif current_user.is_followed_by(user): 25 | return "被ta关注" 26 | return None 27 | -------------------------------------------------------------------------------- /common/templatetags/nav.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.simple_tag(takes_context=True) 7 | def nav_items(context): 8 | user = context["request"].user 9 | nav_props = context.get("nav_props", {}) 10 | items = [ 11 | ['/', '首页'], 12 | ['https://neodb.social/', '书影音'], 13 | ['/group/create', '创建小组'], 14 | ] 15 | if user.is_authenticated: 16 | items.append(['/users/logout/', '登出']) 17 | else: 18 | items.append(['/users/login/?next=/home/', '登录']) 19 | 20 | nav_props.update(items=items) 21 | return nav_props 22 | -------------------------------------------------------------------------------- /common/templatetags/oauth_token.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.conf import settings 3 | from django.utils.html import format_html 4 | 5 | register = template.Library() 6 | 7 | 8 | class OAuthTokenNode(template.Node): 9 | def render(self, context): 10 | request = context.get("request") 11 | oauth_token = request.user.mastodon_token 12 | return format_html(oauth_token) 13 | 14 | 15 | @register.tag 16 | def oauth_token(parser, token): 17 | return OAuthTokenNode() 18 | -------------------------------------------------------------------------------- /common/templatetags/prettydate.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils import timezone 3 | 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.filter 9 | def prettydate(d): 10 | # TODO use date and naturaltime instead https://docs.djangoproject.com/en/3.2/ref/contrib/humanize/ 11 | diff = timezone.now() - d 12 | s = diff.seconds 13 | if diff.days > 14 or diff.days < 0: 14 | return d.strftime("%Y年%m月%d日") 15 | elif diff.days >= 1: 16 | return "{} 天前".format(diff.days) 17 | elif s < 120: 18 | return "刚刚" 19 | elif s < 3600: 20 | return "{} 分钟前".format(s // 60) 21 | else: 22 | return "{} 小时前".format(s // 3600) 23 | -------------------------------------------------------------------------------- /common/templatetags/strip_scheme.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.template.defaultfilters import stringfilter 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.filter(is_safe=True) 8 | @stringfilter 9 | def strip_scheme(value): 10 | """Strip the `https://.../` part of urls""" 11 | if value.startswith("https://"): 12 | value = value.lstrip("https://") 13 | elif value.startswith("http://"): 14 | value = value.lstrip("http://") 15 | 16 | if value.endswith("/"): 17 | value = value[0:-1] 18 | return value 19 | -------------------------------------------------------------------------------- /common/templatetags/thumb.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from easy_thumbnails.templatetags.thumbnail import thumbnail_url 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.filter 8 | def thumb(source, alias): 9 | """ 10 | This filter modifies that from `easy_thumbnails` so that 11 | it can neglect .svg file. 12 | """ 13 | try: 14 | if source.url.endswith(".svg"): 15 | return source.url 16 | else: 17 | return thumbnail_url(source, alias) 18 | except Exception as e: 19 | return "" 20 | -------------------------------------------------------------------------------- /common/templatetags/truncate.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.template.defaultfilters import stringfilter 3 | from django.utils.text import Truncator 4 | 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.filter(is_safe=True) 10 | @stringfilter 11 | def truncate(value, arg): 12 | """Truncate a string after `arg` number of characters.""" 13 | try: 14 | length = int(arg) 15 | except ValueError: # Invalid literal for int(). 16 | return value # Fail silently. 17 | return Truncator(value).chars(length, truncate="...") 18 | -------------------------------------------------------------------------------- /common/tests.py: -------------------------------------------------------------------------------- 1 | # from django.test import TestCase 2 | 3 | # from django.contrib.staticfiles.testing import StaticLiveServerTestCase 4 | # from selenium.webdriver.common.by import By 5 | # from selenium import webdriver 6 | # from selenium.webdriver.firefox.service import Service as FirefoxService 7 | # from webdriver_manager.firefox import GeckoDriverManager 8 | 9 | 10 | # class MySeleniumTests(StaticLiveServerTestCase): 11 | # @classmethod 12 | # def setUpClass(cls): 13 | # super().setUpClass() 14 | # cls.selenium = webdriver.Firefox( 15 | # service=FirefoxService(GeckoDriverManager().install()) 16 | # ) 17 | # cls.selenium.implicitly_wait(10) 18 | 19 | # @classmethod 20 | # def tearDownClass(cls): 21 | # cls.selenium.quit() 22 | # super().tearDownClass() 23 | 24 | # def test_login(self): 25 | # self.selenium.get("%s%s" % (self.live_server_url, "/404/")) 26 | # username_input = self.selenium.find_element(By.NAME, "username") 27 | # username_input.send_keys("myuser") 28 | # password_input = self.selenium.find_element(By.NAME, "password") 29 | # password_input.send_keys("secret") 30 | # self.selenium.find_element(By.XPATH, '//input[@value="Log in"]').click() 31 | -------------------------------------------------------------------------------- /common/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from .views import * 3 | 4 | app_name = "common" 5 | urlpatterns = [path("", home), path("home/", home, name="home")] 6 | -------------------------------------------------------------------------------- /common/utils.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.utils import timezone 3 | 4 | 5 | class PageLinksGenerator: 6 | # TODO inherit django paginator 7 | """ 8 | Calculate the pages for multiple links pagination. 9 | length -- the number of page links in pagination 10 | """ 11 | 12 | def __init__(self, length, current_page, total_pages): 13 | current_page = int(current_page) 14 | self.current_page = current_page 15 | self.previous_page = current_page - 1 if current_page > 1 else None 16 | self.next_page = current_page + 1 if current_page < total_pages else None 17 | self.start_page = None 18 | self.end_page = None 19 | self.page_range = None 20 | self.has_prev = None 21 | self.has_next = None 22 | 23 | start_page = current_page - length // 2 24 | end_page = current_page + length // 2 25 | 26 | # decision is based on the start page and the end page 27 | # both sides overflow 28 | if (start_page < 1 and end_page > total_pages) or length >= total_pages: 29 | self.start_page = 1 30 | self.end_page = total_pages 31 | self.has_prev = False 32 | self.has_next = False 33 | 34 | elif start_page < 1 and not end_page > total_pages: 35 | self.start_page = 1 36 | # this won't overflow because the total pages are more than the length 37 | self.end_page = end_page - (start_page - 1) 38 | self.has_prev = False 39 | if end_page == total_pages: 40 | self.has_next = False 41 | else: 42 | self.has_next = True 43 | 44 | elif not start_page < 1 and end_page > total_pages: 45 | self.end_page = total_pages 46 | self.start_page = start_page - (end_page - total_pages) 47 | self.has_next = False 48 | if start_page == 1: 49 | self.has_prev = False 50 | else: 51 | self.has_prev = True 52 | 53 | # both sides do not overflow 54 | elif not start_page < 1 and not end_page > total_pages: 55 | self.start_page = start_page 56 | self.end_page = end_page 57 | self.has_prev = True 58 | self.has_next = True 59 | 60 | self.first_page = 1 61 | self.last_page = total_pages 62 | self.page_range = range(self.start_page, self.end_page + 1) 63 | # assert self.has_prev is not None and self.has_next is not None 64 | 65 | 66 | def GenerateDateUUIDMediaFilePath(instance, filename, path_root): 67 | ext = filename.split(".")[-1] 68 | filename = "%s.%s" % (uuid.uuid4(), ext) 69 | root = "" 70 | if path_root.endswith("/"): 71 | root = path_root 72 | else: 73 | root = path_root + "/" 74 | return root + timezone.now().strftime("%Y/%m/%d") + f"{filename}" 75 | -------------------------------------------------------------------------------- /common/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from group.models import Group, Topic, Comment 3 | from users.models import User 4 | 5 | 6 | def home(request): 7 | groups = Group.objects.order_by('-id') 8 | my_groups = [] 9 | my_groups_topics = [] 10 | my_groups_comments = [] 11 | last_topics = Topic.objects.order_by('-id')[:20] 12 | last_join_users = User.objects.order_by('-id')[:20] 13 | last_comments = Comment.objects.order_by('-id')[:20] 14 | if request.user.is_authenticated: 15 | my_groups = request.user.groupmember_set.order_by('-id') 16 | my_groups_topics = Topic.objects.filter(group__in=my_groups.values('group_id')).order_by('-id')[:20] 17 | my_groups_comments = Comment.objects.filter(topic__group__in=my_groups.values('group_id')).order_by('-id')[:20] 18 | 19 | return render(request, 'group/home.html', { 20 | 'groups_lists': [([g.group for g in my_groups], '我加入的小组'), (groups, '全部小组')], 21 | 'my_groups_topics': my_groups_topics, 'last_topics': last_topics, 22 | 'my_groups_comments': my_groups_comments, 'last_comments': last_comments, 23 | 'last_join_users': last_join_users, 24 | }) 25 | -------------------------------------------------------------------------------- /group/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/group/__init__.py -------------------------------------------------------------------------------- /group/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /group/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class GroupConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "group" 7 | -------------------------------------------------------------------------------- /group/feeds.py: -------------------------------------------------------------------------------- 1 | from django.contrib.syndication.views import Feed 2 | from django.utils.feedgenerator import Rss201rev2Feed 3 | from django.urls import reverse 4 | from group.models import Group 5 | 6 | 7 | class CorrectMimeTypeFeed(Rss201rev2Feed): 8 | mime_type = 'application/xml' 9 | 10 | 11 | class LatestTopicsFeed(Feed): 12 | language = "zh-cn" 13 | feed_type = CorrectMimeTypeFeed 14 | 15 | def get_object(self, request, group_id): 16 | return Group.objects.get(id=group_id) 17 | 18 | def title(self, obj): 19 | return obj.name 20 | 21 | def link(self, obj): 22 | return reverse("group:group", args=[obj.id]) 23 | 24 | def description(self, obj): 25 | return obj.description 26 | 27 | def items(self, obj): 28 | return obj.topic_set.order_by("-created_at")[:20] 29 | 30 | def item_title(self, item): 31 | return item.title 32 | 33 | def item_description(self, item): 34 | return item.description 35 | 36 | def item_pubdate(self, item): 37 | return item.created_at 38 | 39 | def item_author_name(self, item): 40 | return item.user.mastodon_username 41 | 42 | def item_link(self, item): 43 | return reverse("group:topic", args=[item.id]) -------------------------------------------------------------------------------- /group/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | from group.models import Group, Topic, Comment 4 | from markdownx.fields import MarkdownxFormField 5 | 6 | 7 | class TopicForm(forms.ModelForm): 8 | class Meta: 9 | model = Topic 10 | fields = ('id', 'title', 'description') 11 | title = forms.CharField(max_length=255, required=True, widget=forms.TextInput(attrs={'placeholder': _('添加标题')})) 12 | description = MarkdownxFormField(required=True) 13 | id = forms.IntegerField(required=False) 14 | share_to_mastodon = forms.BooleanField( 15 | label=_("分享到联邦网络"), initial=True, required=False 16 | ) 17 | 18 | 19 | class CommentForm(forms.ModelForm): 20 | class Meta: 21 | model = Comment 22 | fields = ('id', 'content') 23 | 24 | content = forms.CharField(required=True, widget=forms.Textarea(attrs={"rows": 5})) 25 | id = forms.IntegerField(required=False) 26 | comment_reply = forms.IntegerField(required=False, widget=forms.HiddenInput) 27 | share_to_mastodon = forms.BooleanField( 28 | label=_("分享到联邦网络"), initial=True, required=False 29 | ) 30 | 31 | 32 | class GroupSettingsForm(forms.ModelForm): 33 | class Meta: 34 | model = Group 35 | fields = ('id', 'description', 'icon_url') 36 | description = forms.CharField(required=True, widget=forms.Textarea(attrs={"rows": 5})) 37 | id = forms.IntegerField(required=False, widget=forms.HiddenInput) 38 | -------------------------------------------------------------------------------- /group/migrations/0002_alter_group_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-02-05 09:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('group', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='group', 15 | name='name', 16 | field=models.CharField(max_length=255, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /group/migrations/0003_comment_comment_reply.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-02-06 09:13 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("group", "0002_alter_group_name"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="comment", 16 | name="comment_reply", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | to="group.comment", 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /group/migrations/0004_likecomment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-02-06 15:37 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("group", "0003_comment_comment_reply"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="LikeComment", 18 | fields=[ 19 | ( 20 | "id", 21 | models.BigAutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("created_at", models.DateTimeField(auto_now_add=True)), 29 | ("updated_at", models.DateTimeField(auto_now=True)), 30 | ( 31 | "comment", 32 | models.ForeignKey( 33 | on_delete=django.db.models.deletion.CASCADE, to="group.comment" 34 | ), 35 | ), 36 | ( 37 | "user", 38 | models.ForeignKey( 39 | on_delete=django.db.models.deletion.CASCADE, 40 | to=settings.AUTH_USER_MODEL, 41 | ), 42 | ), 43 | ], 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /group/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/group/migrations/__init__.py -------------------------------------------------------------------------------- /group/schema.py: -------------------------------------------------------------------------------- 1 | from marshmallow import Schema as RootSchema, fields 2 | 3 | 4 | class Schema(RootSchema): 5 | class Meta: 6 | datetimeformat = '%Y-%m-%d %H:%M:%S' 7 | 8 | 9 | class UserSchema(Schema): 10 | id = fields.Int() 11 | following = fields.Raw() 12 | mastodon_id = fields.Str() 13 | mastodon_site = fields.Str() 14 | mastodon_token = fields.Str() 15 | mastodon_refresh_token = fields.Str() 16 | mastodon_locked = fields.Bool() 17 | mastodon_followers = fields.Raw() 18 | mastodon_following = fields.Raw() 19 | mastodon_mutes = fields.Raw() 20 | mastodon_blocks = fields.Raw() 21 | mastodon_domain_blocks = fields.Raw() 22 | mastodon_account = fields.Raw() 23 | mastodon_last_refresh = fields.DateTime() 24 | read_announcement_index = fields.Int() 25 | mastodon_username = fields.Str() 26 | 27 | 28 | class Groupchema(Schema): 29 | id = fields.Int() 30 | user = fields.Nested(UserSchema) 31 | name = fields.Str() 32 | description = fields.Str() 33 | created_at = fields.DateTime() 34 | updated_at = fields.DateTime() 35 | icon_url = fields.Str() 36 | absolute_url = fields.Str() 37 | 38 | 39 | class TopicSchema(Schema): 40 | id = fields.Int() 41 | group = fields.Nested(Groupchema) 42 | user = fields.Nested(UserSchema) 43 | title = fields.Str() 44 | description = fields.Str() 45 | type = fields.Int() 46 | created_at = fields.DateTime() 47 | updated_at = fields.DateTime() 48 | absolute_url = fields.Str() 49 | html_content = fields.Str() 50 | comments_count = fields.Int() 51 | 52 | 53 | class BaseCommentSchema(Schema): 54 | id = fields.Int() 55 | user = fields.Nested(UserSchema) 56 | content = fields.Str() 57 | created_at = fields.DateTime() 58 | updated_at = fields.DateTime() 59 | absolute_url = fields.Str() 60 | like_count = fields.Int() 61 | is_liked = fields.Bool() 62 | 63 | 64 | class CommentSchema(BaseCommentSchema): 65 | comment_reply = fields.Nested(BaseCommentSchema) 66 | 67 | 68 | class GroupMemberSchema(Schema): 69 | user = fields.Nested(UserSchema) 70 | group = fields.Nested(Groupchema) 71 | join_reason = fields.Str() 72 | created_at = fields.DateTime() 73 | updated_at = fields.DateTime() 74 | -------------------------------------------------------------------------------- /group/static/catalog.js: -------------------------------------------------------------------------------- 1 | function catalog_init(context) { 2 | // readonly star rating of detail display section 3 | let ratingLabels = $(".grid__main .rating-star", context); 4 | $(ratingLabels).each( function(index, value) { 5 | let ratingScore = $(this).data("rating-score") / 2; 6 | $(this).starRating({ 7 | initialRating: ratingScore, 8 | readOnly: true, 9 | }); 10 | }); 11 | // readonly star rating at aside section 12 | ratingLabels = $("#aside .rating-star"), context; 13 | $(ratingLabels).each( function(index, value) { 14 | let ratingScore = $(this).data("rating-score") / 2; 15 | $(this).starRating({ 16 | initialRating: ratingScore, 17 | readOnly: true, 18 | starSize: 15, 19 | }); 20 | }); 21 | // hide long text 22 | $(".entity-desc__content", context).each(function() { 23 | let copy = $(this).clone() 24 | .addClass('entity-desc__content--folded') 25 | .css("visibility", "hidden"); 26 | $(this).after(copy); 27 | if ($(this).height() > copy.height()) { 28 | $(this).addClass('entity-desc__content--folded'); 29 | $(this).siblings(".entity-desc__unfold-button").removeClass("entity-desc__unfold-button--hidden"); 30 | } 31 | copy.remove(); 32 | }); 33 | 34 | // expand hidden long text 35 | $(".entity-desc__unfold-button a", context).on('click', function() { 36 | $(this).parent().siblings(".entity-desc__content").removeClass('entity-desc__content--folded'); 37 | $(this).parent(".entity-desc__unfold-button").remove(); 38 | }); 39 | } 40 | 41 | $(function() { 42 | document.body.addEventListener('htmx:load', function(evt) { 43 | catalog_init(evt.detail.elt); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /group/templates/group/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load i18n %} 3 | {% load admin_url %} 4 | {% load mastodon %} 5 | {% load oauth_token %} 6 | {% load truncate %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% block extra_head %} 15 | 16 | 17 | {% endblock %} 18 | 19 | 20 | 21 | 22 | {{ site_name }} - {%block title%}{%endblock%} 23 | 24 | {% include "libs/common.html" with jquery=1 %} 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 |
33 | {% include "partial/_navbar.html" with current="home" %} 34 |
35 |
36 |
37 |
38 | {%block content%} 39 | {%endblock%} 40 |
41 |
42 | {% block sidebar %} 43 | {% endblock %} 44 |
45 |
46 |
47 | {% include "partial/_footer.html" %} 48 |
49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /group/templates/group/create.html: -------------------------------------------------------------------------------- 1 | {% extends "group/base.html" %} 2 | {% block title %}申请创建小组{% endblock %} 3 | 4 | {% block content %} 5 |

创建小组

6 |
7 |
8 |
{% csrf_token %} 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 | {% endblock %} -------------------------------------------------------------------------------- /group/templates/group/group.html: -------------------------------------------------------------------------------- 1 | {% extends "group/react_base.html" %} 2 | {% block extra_head %} 3 | 4 | 5 | 6 | {% endblock %} 7 | 8 | {% load react %} 9 | {% load static %} 10 | 11 | {% block main %} 12 | {% react_render component="Group" props=group_props no_placeholder=1 %} 13 | {% endblock %} 14 | 15 | {% block sidebar %} 16 | {% react_render component="GroupSidebar" props=sidebar no_placeholder=1 %} 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /group/templates/group/group_edit.html: -------------------------------------------------------------------------------- 1 | {% extends "group/base.html" %} 2 | 3 | {% block title %}{{group.name}}{% endblock %} 4 | 5 | {% block content %} 6 |
{% csrf_token %} 7 | {{ form }} 8 | 9 |
10 | {% endblock %} -------------------------------------------------------------------------------- /group/templates/group/home.html: -------------------------------------------------------------------------------- 1 | {% extends "group/base.html" %} 2 | {% block title %}Home{% endblock %} 3 | 4 | {% block content %} 5 | {% load thumb %} 6 | 7 | {% if my_groups_topics %} 8 |
9 |
我加入的小组最新帖子
10 | 17 |
18 |
19 | {% endif %} 20 | 21 | {% if my_groups_comments %} 22 |
23 |
我加入的小组最新回复
24 | 31 |
32 |
33 | {% endif %} 34 | 35 | {% for gl in groups_lists %} 36 | {% if gl.0 %} 37 |
38 |
{{gl.1}}
39 | 54 |
55 |
56 | {% endif %} 57 | {% endfor %} 58 | 59 |
60 |
最新帖子
61 | 68 |
69 | 70 |
71 |
72 |
最新回复
73 | 80 |
81 | 82 |
83 |
84 |
最新加入的用户
85 | 93 |
94 | {% endblock %} 95 | 96 | {% block sidebar %} 97 | {% if request.user.is_authenticated %} 98 | {%include "partial/_sidebar.html"%} 99 | {% endif %} 100 | {% endblock %} 101 | -------------------------------------------------------------------------------- /group/templates/group/new_topic.html: -------------------------------------------------------------------------------- 1 | {% extends "group/base.html" %} 2 | {% block title %}{{group.name}}{% endblock %} 3 | 4 | {% block content %} 5 | {% load static %} 6 | 25 |
{% csrf_token %} 26 | {{ form.title }} 27 | 28 | 预览 29 | 30 |
31 | {{ form.description }} 32 |
33 |
34 | {{ form.share_to_mastodon }}{{ form.share_to_mastodon.label }} 35 |
36 | 37 | 38 |
39 | {% endblock %} 40 | 41 | {% block sidebar %} 42 | {% include "group/sidebar_group.html" %} 43 | {% endblock %} 44 | 45 | {% block footer %} 46 | {% include "group/footer.html" %} 47 | {% endblock %} -------------------------------------------------------------------------------- /group/templates/group/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "group/base.html" %} 2 | {% block title %}{{user.mastodon_username}}{% endblock %} 3 | 4 | {% block content %} 5 |
6 | 7 |
{{user.mastodon_username}}
8 |
9 | 10 |
加入的小组
11 | 16 | 17 |
18 |
最新帖子
19 | 24 | 25 |
26 |
最新回复
27 | 32 | {% endblock %} 33 | 34 | {% block sidebar %} 35 | {% include "partial/_sidebar.html" %} 36 | {% endblock %} -------------------------------------------------------------------------------- /group/templates/group/react_base.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load static %} 3 | {% load react %} 4 | {% load admin_url %} 5 | {% load nav %} 6 | {% load oauth_token %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% block extra_head %} 15 | 16 | 17 | {% endblock %} 18 | 19 | 20 | 21 | {% block title %}{{title}}{% endblock %} - {{ site_name }} 22 | 23 | 24 | 25 | 26 | {% block body %} 27 | {% nav_items as nav_props %} 28 | {% react_render component="Nav" props=nav_props no_placeholder=1%} 29 | {% block content %} 30 |
31 |
32 | {% block main %} 33 | {% endblock %} 34 |
35 | 36 | {% block sidebar %} 37 | {% endblock %} 38 | 39 |
40 | {% endblock %} 41 | 42 | {% endblock %} 43 | 44 | 45 | 46 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /group/templates/group/react_new_topic.html: -------------------------------------------------------------------------------- 1 | {% extends "group/react_base.html" %} 2 | 3 | {% load react %} 4 | {% load static %} 5 | 6 | {% block extra_head %} 7 | 8 | 9 | 10 | {% endblock %} 11 | 12 | {% block content %} 13 |
14 |
15 |
16 |
17 | 18 | 23 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /group/templates/group/sidebar.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load i18n %} 3 | {% load admin_url %} 4 | {% load mastodon %} 5 | {% load oauth_token %} 6 | {% load truncate %} 7 | {% load thumb %} 8 | 20 |
21 |
22 |
23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 |
31 |
32 |
33 |
34 | {% trans '最近加入' %} 35 |
36 | 48 |
49 |
50 |
    51 | {% if group.user == request.user %} 52 |
  • > 成员管理
  • 53 |
  • > 小组管理
  • 54 | {% endif %} 55 | {% if is_member %} 56 |
  • > 退出小组
  • 57 | {% endif %} 58 |
59 |
60 |
61 |
62 | 63 |
64 |
65 | 66 | {% if user == request.user %} 67 | 68 | 69 | 70 | 71 | 87 | {% endif %} -------------------------------------------------------------------------------- /group/templates/group/sidebar_group.html: -------------------------------------------------------------------------------- 1 | {% load thumb %} 2 |
3 |
4 |
5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 |
14 |
15 |
16 | {{group.name}} 17 |
18 |
19 |
20 |
最新讨论
21 |
    22 | {% for t in last_topics %} 23 |
  • {{t.title}} ({{t.user.mastodon_username}})
  • 24 | {% endfor %} 25 |
26 |
27 |
28 |
29 | 30 |
31 |
32 | 33 | {% if user == request.user %} 34 | 35 | 36 | 37 | 38 | 54 | {% endif %} -------------------------------------------------------------------------------- /group/templates/group/topic.html: -------------------------------------------------------------------------------- 1 | {% extends "group/react_base.html" %} 2 | {% block extra_head %} 3 | 4 | 5 | {% endblock %} 6 | 7 | {% load react %} 8 | {% load static %} 9 | 10 | {% block main %} 11 | {% react_render component="Topic" props=topic no_placeholder=1 %} 12 | {% endblock %} 13 | 14 | {% block sidebar %} 15 | {% react_render component="TopicSidebar" props=sidebar no_placeholder=1 %} 16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /group/templates/libs/common.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load tz_detect %} 3 | {% tz_detect %} 4 | {% if sentry_dsn %} 5 | 6 | {% endif %} 7 | {% if jquery %} 8 | 9 | {% else %} 10 | 11 | {% endif %} 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /group/templates/react_print.html: -------------------------------------------------------------------------------- 1 | {% if components %} 2 | {% for component in components %} 3 | {{ component.json_obj|json_script:component.data_identifier }} 4 | 14 | {% endfor %} 15 | {% endif %} 16 | -------------------------------------------------------------------------------- /group/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/group/templatetags/__init__.py -------------------------------------------------------------------------------- /group/templatetags/neogroup.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from group.models import LikeComment 3 | 4 | register = template.Library() 5 | 6 | @register.simple_tag(takes_context=True) 7 | def is_like_comment(context, comment): 8 | user = context["request"].user 9 | if user and user.is_authenticated: 10 | return LikeComment.objects.filter(user=user, comment=comment).first() is not None 11 | return False -------------------------------------------------------------------------------- /group/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /group/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from group.views import * 3 | from group.feeds import LatestTopicsFeed 4 | from django.contrib.auth.decorators import login_required 5 | 6 | 7 | app_name = "group" 8 | urlpatterns = [ 9 | path("create/", login_required(create), name="create"), 10 | path("/new_topic", login_required(new_topic), name="new_topic"), 11 | path("/join", j_login_required(join), name="join"), 12 | path("/leave", j_login_required(leave), name="leave"), 13 | path("/group_edit", login_required(group_edit), name="group_edit"), 14 | path("/topics", get_topics, name="topics"), 15 | path("/", group, name="group"), 16 | path("topic//", topic, name="topic"), 17 | path("topic//comments", get_comments, name="comments"), 18 | path("topic//delete", delete_topic, name="delete_topic"), 19 | path("comment//delete", delete_comment, name="delete_comment"), 20 | path("comment//like", j_login_required(like_comment), name="like_comment"), 21 | path("profile//", profile, name="profile"), 22 | path("/feed/", LatestTopicsFeed(), name="feed"), 23 | ] -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "neogroup.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /mastodon/__init__.py: -------------------------------------------------------------------------------- 1 | from .decorators import * 2 | -------------------------------------------------------------------------------- /mastodon/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import * 3 | from .api import create_app 4 | from django.utils.translation import gettext_lazy as _ 5 | from requests.exceptions import Timeout 6 | from django.core.exceptions import ObjectDoesNotExist 7 | 8 | # Register your models here. 9 | @admin.register(MastodonApplication) 10 | class MastodonApplicationModelAdmin(admin.ModelAdmin): 11 | def add_view(self, request, form_url="", extra_context=None): 12 | """ 13 | Dirty code here, use POST['domain_name'] to pass error message to user. 14 | """ 15 | if request.method == "POST": 16 | if not request.POST.get("client_id") and not request.POST.get( 17 | "client_secret" 18 | ): 19 | # make the post data mutable 20 | request.POST = request.POST.copy() 21 | # (is_proxy xor proxy_to) or (proxy_to!=null and is_proxy=false) 22 | if ( 23 | ( 24 | bool(request.POST.get("is_proxy")) 25 | or bool(request.POST.get("proxy_to")) 26 | ) 27 | and not ( 28 | bool(request.POST.get("is_proxy")) 29 | and bool(request.POST.get("proxy_to")) 30 | ) 31 | or ( 32 | not bool(request.POST.get("is_proxy")) 33 | and bool(request.POST.get("proxy_to")) 34 | ) 35 | ): 36 | request.POST["domain_name"] = _("请同时填写is_proxy和proxy_to。") 37 | else: 38 | if request.POST.get("is_proxy"): 39 | try: 40 | origin = MastodonApplication.objects.get( 41 | domain_name=request.POST["proxy_to"] 42 | ) 43 | # set proxy credentials to those of its original site 44 | request.POST["app_id"] = origin.app_id 45 | request.POST["client_id"] = origin.client_id 46 | request.POST["client_secret"] = origin.client_secret 47 | request.POST["vapid_key"] = origin.vapid_key 48 | except ObjectDoesNotExist: 49 | request.POST["domain_name"] = _("proxy_to所指域名不存在,请先添加原站点。") 50 | else: 51 | # create mastodon app 52 | try: 53 | response = create_app(request.POST.get("domain_name")) 54 | except (Timeout, ConnectionError): 55 | request.POST["domain_name"] = _("联邦网络请求超时。") 56 | except Exception as e: 57 | request.POST["domain_name"] = str(e) 58 | else: 59 | # fill the form with returned data 60 | data = response.json() 61 | if response.status_code != 200: 62 | request.POST["domain_name"] = str(data) 63 | else: 64 | request.POST["app_id"] = data["id"] 65 | request.POST["client_id"] = data["client_id"] 66 | request.POST["client_secret"] = data["client_secret"] 67 | request.POST["vapid_key"] = data["vapid_key"] 68 | 69 | return super().add_view(request, form_url=form_url, extra_context=extra_context) 70 | 71 | 72 | admin.site.register(CrossSiteUserInfo) 73 | -------------------------------------------------------------------------------- /mastodon/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MastodonConfig(AppConfig): 5 | name = "mastodon" 6 | -------------------------------------------------------------------------------- /mastodon/auth.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.backends import ModelBackend, UserModel 2 | from .api import verify_account 3 | 4 | 5 | class OAuth2Backend(ModelBackend): 6 | """Used to glue OAuth2 and Django User model""" 7 | 8 | # "authenticate() should check the credentials it gets and returns 9 | # a user object that matches those credentials." 10 | # arg request is an interface specification, not used in this implementation 11 | 12 | def authenticate(self, request, token=None, username=None, site=None, **kwargs): 13 | """when username is provided, assume that token is newly obtained and valid""" 14 | if token is None or site is None: 15 | return 16 | 17 | if username is None: 18 | code, user_data = verify_account(site, token) 19 | if code == 200: 20 | userid = user_data["id"] 21 | else: 22 | # aquiring user data fail means token is invalid thus auth fail 23 | return None 24 | 25 | # when username is provided, assume that token is newly obtained and valid 26 | try: 27 | user = UserModel._default_manager.get( 28 | mastodon_id=userid, mastodon_site=site 29 | ) 30 | except UserModel.DoesNotExist: 31 | return None 32 | else: 33 | if self.user_can_authenticate(user): 34 | return user 35 | return None 36 | -------------------------------------------------------------------------------- /mastodon/decorators.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | import functools 3 | from django.shortcuts import render 4 | from django.utils.translation import gettext_lazy as _ 5 | from requests.exceptions import Timeout 6 | 7 | 8 | def mastodon_request_included(func): 9 | """Handles timeout exception of requests to mastodon, returns http 500""" 10 | 11 | @functools.wraps(func) 12 | def wrapper(*args, **kwargs): 13 | try: 14 | return func(*args, **kwargs) 15 | except (Timeout, ConnectionError): 16 | return render( 17 | args[0], "common/error.html", {"msg": _("联邦网络请求超时叻_(´ཀ`」 ∠)__ ")} 18 | ) 19 | 20 | return wrapper 21 | 22 | 23 | class HttpResponseInternalServerError(HttpResponse): 24 | status_code = 500 25 | -------------------------------------------------------------------------------- /mastodon/management/commands/wrong_sites.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from mastodon.models import MastodonApplication 3 | from django.conf import settings 4 | from mastodon.api import get_instance_info 5 | from users.models import User 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Find wrong sites" 10 | 11 | def handle(self, *args, **options): 12 | for site in MastodonApplication.objects.all(): 13 | d = site.domain_name 14 | login_domain = ( 15 | d.strip().lower().split("//")[-1].split("/")[0].split("@")[-1] 16 | ) 17 | domain, version = get_instance_info(login_domain) 18 | if d != domain: 19 | print(f"{d} should be {domain}") 20 | for u in User.objects.filter(mastodon_site=d, is_active=True): 21 | u.mastodon_site = domain 22 | print(f"fixing {u}") 23 | u.save() 24 | -------------------------------------------------------------------------------- /mastodon/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-01-26 13:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="CrossSiteUserInfo", 15 | fields=[ 16 | ( 17 | "id", 18 | models.BigAutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ( 26 | "uid", 27 | models.CharField( 28 | max_length=200, verbose_name="username and original site" 29 | ), 30 | ), 31 | ( 32 | "local_id", 33 | models.PositiveIntegerField(verbose_name="local database id"), 34 | ), 35 | ( 36 | "target_site", 37 | models.CharField( 38 | max_length=100, verbose_name="target site domain name" 39 | ), 40 | ), 41 | ("site_id", models.CharField(max_length=100)), 42 | ], 43 | ), 44 | migrations.CreateModel( 45 | name="MastodonApplication", 46 | fields=[ 47 | ( 48 | "id", 49 | models.BigAutoField( 50 | auto_created=True, 51 | primary_key=True, 52 | serialize=False, 53 | verbose_name="ID", 54 | ), 55 | ), 56 | ( 57 | "domain_name", 58 | models.CharField( 59 | max_length=100, unique=True, verbose_name="site domain name" 60 | ), 61 | ), 62 | ( 63 | "app_id", 64 | models.CharField(max_length=100, verbose_name="in-site app id"), 65 | ), 66 | ( 67 | "client_id", 68 | models.CharField(max_length=100, verbose_name="client id"), 69 | ), 70 | ( 71 | "client_secret", 72 | models.CharField(max_length=100, verbose_name="client secret"), 73 | ), 74 | ( 75 | "vapid_key", 76 | models.CharField( 77 | blank=True, max_length=200, null=True, verbose_name="vapid key" 78 | ), 79 | ), 80 | ( 81 | "star_mode", 82 | models.PositiveIntegerField( 83 | default=0, 84 | verbose_name="0: custom emoji; 1: unicode moon; 2: text", 85 | ), 86 | ), 87 | ( 88 | "max_status_len", 89 | models.PositiveIntegerField( 90 | default=500, verbose_name="max toot len" 91 | ), 92 | ), 93 | ("is_proxy", models.BooleanField(blank=True, default=False)), 94 | ("proxy_to", models.CharField(blank=True, default="", max_length=100)), 95 | ], 96 | ), 97 | migrations.AddConstraint( 98 | model_name="crosssiteuserinfo", 99 | constraint=models.UniqueConstraint( 100 | fields=("uid", "target_site"), name="unique_cross_site_user_info" 101 | ), 102 | ), 103 | ] 104 | -------------------------------------------------------------------------------- /mastodon/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/mastodon/migrations/__init__.py -------------------------------------------------------------------------------- /mastodon/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class MastodonApplication(models.Model): 7 | domain_name = models.CharField(_("site domain name"), max_length=100, unique=True) 8 | app_id = models.CharField(_("in-site app id"), max_length=100) 9 | client_id = models.CharField(_("client id"), max_length=100) 10 | client_secret = models.CharField(_("client secret"), max_length=100) 11 | vapid_key = models.CharField(_("vapid key"), max_length=200, null=True, blank=True) 12 | star_mode = models.PositiveIntegerField( 13 | _("0: custom emoji; 1: unicode moon; 2: text"), blank=False, default=0 14 | ) 15 | max_status_len = models.PositiveIntegerField( 16 | _("max toot len"), blank=False, default=500 17 | ) 18 | 19 | is_proxy = models.BooleanField(default=False, blank=True) 20 | proxy_to = models.CharField(max_length=100, blank=True, default="") 21 | # website 22 | # name 23 | # redirect_uris 24 | def __str__(self): 25 | return self.domain_name 26 | 27 | 28 | class CrossSiteUserInfo(models.Model): 29 | # username@original_site 30 | uid = models.CharField(_("username and original site"), max_length=200) 31 | # pk in the boofilsic db 32 | local_id = models.PositiveIntegerField(_("local database id")) 33 | # target site domain name 34 | target_site = models.CharField(_("target site domain name"), max_length=100) 35 | # target site id 36 | site_id = models.CharField(max_length=100, blank=False) 37 | 38 | class Meta: 39 | constraints = [ 40 | models.UniqueConstraint( 41 | fields=["uid", "target_site"], name="unique_cross_site_user_info" 42 | ) 43 | ] 44 | 45 | def __str__(self): 46 | return f"{self.uid}({self.local_id}) in {self.target_site}({self.site_id})" 47 | -------------------------------------------------------------------------------- /mastodon/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def rating_to_emoji(score, star_mode=0): 5 | """convert score to mastodon star emoji code""" 6 | if score is None or score == "" or score == 0: 7 | return "" 8 | solid_stars = score // 2 9 | half_star = int(bool(score % 2)) 10 | empty_stars = 5 - solid_stars if not half_star else 5 - solid_stars - 1 11 | if star_mode == 1: 12 | emoji_code = "🌕" * solid_stars + "🌗" * half_star + "🌑" * empty_stars 13 | else: 14 | emoji_code = ( 15 | settings.STAR_SOLID * solid_stars 16 | + settings.STAR_HALF * half_star 17 | + settings.STAR_EMPTY * empty_stars 18 | ) 19 | emoji_code = emoji_code.replace("::", ": :") 20 | emoji_code = " " + emoji_code + " " 21 | return emoji_code 22 | -------------------------------------------------------------------------------- /neogroup/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/neogroup/__init__.py -------------------------------------------------------------------------------- /neogroup/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for neogroup project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "neogroup.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /neogroup/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def site_info(request): 5 | return settings.SITE_INFO -------------------------------------------------------------------------------- /neogroup/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | from django.conf import settings 4 | from users.views import login 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | path("login/", login), 9 | path("markdownx/", include("markdownx.urls")), 10 | path("users/", include("users.urls")), 11 | path("group/", include("group.urls")), 12 | path("", include("common.urls")), 13 | path("tz_detect/", include("tz_detect.urls")), 14 | ] 15 | 16 | # if settings.DEBUG: 17 | # from django.conf.urls.static import static 18 | # urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 19 | 20 | if settings.DEBUG: 21 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 22 | urlpatterns += staticfiles_urlpatterns() 23 | 24 | from django.conf.urls.static import static 25 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 26 | -------------------------------------------------------------------------------- /neogroup/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for neogroup project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "neogroup.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dateparser 2 | Django==4.1.5 3 | django-markdownx @ git+https://github.com/alphatownsman/django-markdownx.git@e69480c64ad9c5d0499f4a8625da78cf2bb7691b 4 | django-rq 5 | django-simple-history 6 | django-user-messages 7 | django-sass 8 | django-ninja 9 | django-maintenance-mode 10 | django-tz-detect 11 | meilisearch 12 | easy-thumbnails 13 | requests 14 | markdownify 15 | opencc 16 | hiredis==2.1.1 17 | django_react_templatetags 18 | marshmallow 19 | hypernova 20 | -------------------------------------------------------------------------------- /static_source/hypernova-bootstrap.js: -------------------------------------------------------------------------------- 1 | // create-react-app requirement 2 | process.env.NODE_ENV = 'production'; 3 | 4 | require('@babel/register')({ 5 | ignore: [ /(node_modules)/], 6 | presets: ["@babel/preset-env", '@babel/preset-react'], 7 | }); 8 | 9 | require.extensions['.scss'] = () => {}; 10 | require.extensions['.css'] = () => {}; 11 | require('./src/hypernova'); 12 | -------------------------------------------------------------------------------- /static_source/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "neogrp", 3 | "version": "1.2.0", 4 | "scripts": { 5 | "start": "vite", 6 | "dev": "vite build -w", 7 | "build": "vite build" 8 | }, 9 | "dependencies": { 10 | "axios": "^1.3.3", 11 | "classnames": "^2.3.2", 12 | "date-fns": "^2.29.3", 13 | "hypernova": "^2.5.0", 14 | "hypernova-react": "^2.1.0", 15 | "image-extensions": "^1.1.0", 16 | "is-url": "^1.2.4", 17 | "isomorphic-dompurify": "^1.0.0", 18 | "js-cookie": "^3.0.1", 19 | "process": "^0.11.10", 20 | "react": "^16.13.1", 21 | "react-dom": "^16.13.1", 22 | "react-icons": "^4.8.0", 23 | "react-paginate": "^8.1.4", 24 | "remark-slate-transformer": "^0.7.4", 25 | "sass": "^1.58.0", 26 | "slate": "^0.91.4", 27 | "slate-history": "^0.93.0", 28 | "slate-hyperscript": "^0.77.0", 29 | "slate-react": "^0.91.11" 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.20.12", 33 | "@babel/preset-env": "^7.20.2", 34 | "@babel/preset-react": "^7.18.6", 35 | "@babel/register": "^7.18.9", 36 | "@vitejs/plugin-react": "^3.0.1", 37 | "esm": "^3.2.25", 38 | "vite": "^4.0.4" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /static_source/src/App.jsx: -------------------------------------------------------------------------------- 1 | import {isBrowser} from './common/utils'; 2 | import withHypernova from "./common/withHypernova"; 3 | import Nav from './components/Nav'; 4 | import {TopicSidebar, GroupSidebar} from "./components/Sidebar"; 5 | import {Topic, Group} from './pages'; 6 | 7 | const clientExport = { 8 | Nav: withHypernova('Nav')(Nav), 9 | Topic: withHypernova('Topic')(Topic), 10 | TopicSidebar, 11 | Group: withHypernova('Group')(Group), 12 | GroupSidebar 13 | } 14 | 15 | const serverExport = { 16 | Nav, 17 | Topic, 18 | TopicSidebar, 19 | Group, 20 | GroupSidebar 21 | } 22 | 23 | export default isBrowser() ? clientExport : serverExport; 24 | -------------------------------------------------------------------------------- /static_source/src/common/axios.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Cookie from 'js-cookie'; 3 | 4 | axios.defaults.xsrfHeaderName = 'X-CSRFTOKEN'; 5 | axios.defaults.xsrfCookieName = 'csrftoken'; 6 | 7 | const axiosInstance = axios.create({ 8 | withCredentials: true, 9 | headers: { 10 | 'X-CSRFTOKEN': Cookie.get('csrftoken'), 11 | 'Content-Type': 'application/json', 12 | }, 13 | }); 14 | 15 | export default axiosInstance; 16 | -------------------------------------------------------------------------------- /static_source/src/common/color.scss: -------------------------------------------------------------------------------- 1 | $white: #fffff8; 2 | $white2: #f8f8f8; 3 | $gray0: #e0e1dc; 4 | $gray: #cccccc; 5 | $gray2:#6a6b6e; 6 | $black0:#3c3c3c; 7 | $black: #2B2B2B; 8 | $purple0: #d9d9d9; 9 | $purple: #c0b5e6; 10 | $purple2: #a493da; 11 | $purple3: #8671B5; 12 | $purple4: #6a5c89; 13 | $purple5: #3c3352; 14 | -------------------------------------------------------------------------------- /static_source/src/common/isServer.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export const useIsServer = () => { 4 | const [isServer, setIsServer] = useState(true); 5 | useEffect(() => { 6 | setIsServer(false); 7 | }, []); 8 | return isServer; 9 | }; 10 | 11 | function ServerOnly ({ children = null, onClient = null}) { 12 | const isServer = useIsServer() 13 | return isServer 14 | ? children 15 | : onClient 16 | } 17 | 18 | export default ServerOnly; 19 | -------------------------------------------------------------------------------- /static_source/src/common/sidebar.scss: -------------------------------------------------------------------------------- 1 | @import './color.scss'; 2 | 3 | .sidebar { 4 | color: $black0; 5 | &-section { 6 | &-title { 7 | display: flex; 8 | font-size: 1.6rem; 9 | margin-bottom: 0.4rem; 10 | } 11 | } 12 | .operations { 13 | padding-top: 1.5rem; 14 | .operation { 15 | padding: 0.2rem; 16 | display: block; 17 | &:before { 18 | content: '>'; 19 | z-index: 1; 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /static_source/src/common/utils.jsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useLayoutEffect} from 'react'; 2 | import {intervalToDuration} from 'date-fns'; 3 | 4 | export function getDisplayDate (date1, date2, exactMode = false) { 5 | const diff = intervalToDuration({ 6 | start: date1, 7 | end: date2, 8 | }); 9 | const {years, months, days, hours, minutes, seconds} = diff; 10 | 11 | if (years > 0 || months > 0 || days > 0) { 12 | if (!exactMode) { 13 | if (years > 0) { 14 | return `${years}年前`; 15 | } 16 | if (months > 0) { 17 | return `${months}月前`; 18 | } 19 | if (days > 0) { 20 | return `${days}天前`; 21 | } 22 | } else { 23 | return date1.toISOString().slice(0, 10); 24 | } 25 | } 26 | 27 | if (hours > 0) { 28 | return `${hours}小时前`; 29 | } 30 | if (minutes > 0) { 31 | return `${minutes}分钟前`; 32 | } 33 | if (seconds > 0) { 34 | return `${seconds}秒前`; 35 | } 36 | return '刚刚'; 37 | } 38 | 39 | export function isBrowser () { 40 | return !!(typeof window !== 'undefined' && window.document && window.document.createElement); 41 | } 42 | 43 | export const useIsomorphicLayoutEffect = isBrowser() ? useLayoutEffect : useEffect; 44 | 45 | export function FediIcon () { 46 | return 47 | 分享到联邦宇宙 48 | 53 | 54 | } 55 | 56 | export function SendIcon (props) { 57 | return 58 | send 59 | 60 | 61 | 62 | 63 | } 64 | -------------------------------------------------------------------------------- /static_source/src/common/utils.scss: -------------------------------------------------------------------------------- 1 | @mixin line-clamp($numLines: 1, $lineHeight: 1.4) { 2 | overflow: hidden; 3 | text-overflow: ellipsis; 4 | display: -webkit-box; 5 | text-overflow: -o-ellipsis-lastline; 6 | -webkit-line-clamp: $numLines; 7 | -webkit-box-orient: vertical; 8 | max-height: $numLines * $lineHeight + unquote('rem'); 9 | } 10 | -------------------------------------------------------------------------------- /static_source/src/common/withHypernova.js: -------------------------------------------------------------------------------- 1 | import { renderReact } from 'hypernova-react'; 2 | 3 | const withHypernova = (displayName) => (Component) => { 4 | return renderReact(displayName, Component); 5 | }; 6 | 7 | export default withHypernova; 8 | -------------------------------------------------------------------------------- /static_source/src/common/wrapInBaseContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const wrapInAppContainer = (WrappedComponent) => { 4 | const WrappedInApp = (props) => { 5 | return 6 | 7 | }; 8 | 9 | WrappedInApp.displayName = WrappedInApp(WrappedComponent.name); 10 | 11 | return WrappedInApp; 12 | }; 13 | 14 | export default wrapInAppContainer; 15 | -------------------------------------------------------------------------------- /static_source/src/components/Author/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | import DOMPurify from 'isomorphic-dompurify'; 4 | import './style.scss'; 5 | import {getDisplayDate} from '../../common/utils'; 6 | 7 | function Author (props) { 8 | const {mastodon_username, mastodon_account, authored_at, showNote = false} = props; 9 | const { 10 | display_name, 11 | avatar, 12 | note, 13 | url, 14 | } = mastodon_account; 15 | 16 | return ( 17 |
18 |
19 | 20 | {mastodon_username} 21 | 22 |
23 |
24 |
25 |
26 | {display_name} 27 | 28 | {mastodon_username} 29 | 30 | 31 |
32 | {showNote && note &&
} 38 |
39 |
40 | {getDisplayDate(new Date(authored_at), new Date())} 41 |
42 |
43 |
44 | ) 45 | ; 46 | } 47 | 48 | export default Author; 49 | -------------------------------------------------------------------------------- /static_source/src/components/Author/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../common/color.scss'; 2 | @import '../../common/utils.scss'; 3 | 4 | .author { 5 | width: 100%; 6 | display: flex; 7 | align-items: center; 8 | 9 | .left-side { 10 | flex: 1 0 auto; 11 | height: 1.6rem; 12 | 13 | &.center { 14 | display: flex; 15 | align-items: center; 16 | } 17 | 18 | .topic-author { 19 | .avatar { 20 | width: 2.5rem; 21 | border-radius: 50%; 22 | vertical-align: middle; 23 | } 24 | } 25 | } 26 | 27 | .right-side { 28 | width: calc(100% - 3rem); 29 | display: flex; 30 | justify-content: space-between; 31 | font-size: 0.8rem; 32 | color: $gray2; 33 | align-items: flex-start; 34 | 35 | .topic { 36 | &-user { 37 | max-width: 80%; 38 | 39 | &-username { 40 | white-space: nowrap; 41 | text-overflow: ellipsis; 42 | overflow: hidden; 43 | 44 | .display-name { 45 | color: $black; 46 | font-weight: 500; 47 | font-size: 1.1rem; 48 | margin-right: 0.2rem; 49 | } 50 | } 51 | 52 | .note { 53 | p, div { 54 | margin: 0; 55 | } 56 | white-space: nowrap; 57 | @include line-clamp(1); 58 | } 59 | } 60 | } 61 | 62 | .authored-at { 63 | padding-top: 0.5rem; 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /static_source/src/components/Card/Group/index.jsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react'; 2 | import './style.scss'; 3 | import axiosInstance from '../../../common/axios'; 4 | 5 | 6 | function GroupCard (props) { 7 | const [joinState, setJoinState] = useState(props.isMember) 8 | const { 9 | created_at, 10 | description, 11 | icon_url, 12 | id, 13 | name, 14 | updated_at, 15 | user, 16 | absolute_url, 17 | onJoinStateChange 18 | } = props; 19 | 20 | useEffect(() => { 21 | onJoinStateChange && onJoinStateChange(joinState) 22 | }, [joinState]) 23 | 24 | 25 | return ( 26 |
27 |
28 |
29 | 30 | 33 | 34 |
35 |
36 | {name} 37 |
38 |
39 | {user.mastodon_account.display_name} 40 |
41 | 创建于:{created_at.slice(0, 10)} 42 |
43 |
44 | 45 |
46 |
{ 47 | const action = joinState ? 'leave' : 'join' 48 | if (action == 'leave') { 49 | if (!window.confirm('确定要退出该小组吗?')) { 50 | return 51 | } 52 | } 53 | await axiosInstance.post(`/group/${id}/${action}`).then((res) => { 54 | if (res.data.r == 0) { 55 | setJoinState(!joinState) 56 | } 57 | else { 58 | alert(res.data.msg || "出错了,请联系管理员") 59 | } 60 | }).catch((err) => { 61 | if (err.response && err.response.status === 403) { 62 | alert("请先登录"); 63 | } 64 | }) 65 | } 66 | }> 67 | { 68 | joinState ? '已加入' : '加入' 69 | } 70 |
71 |
72 | 73 |
74 | 75 | 简介 76 | 77 |
78 | {description} 79 |
80 |
81 |
82 | ); 83 | } 84 | 85 | export default GroupCard; 86 | -------------------------------------------------------------------------------- /static_source/src/components/Card/Group/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../../common/color.scss'; 2 | 3 | .group-card { 4 | display: flex; 5 | width: 100%; 6 | flex-direction: column; 7 | color: $gray2; 8 | &-hd { 9 | display: flex; 10 | justify-content: space-between; 11 | .group { 12 | &-info { 13 | display: flex; 14 | img { 15 | border-radius: 0.5rem; 16 | width: 4.5rem; 17 | } 18 | } 19 | &-base { 20 | display: flex; 21 | flex-direction: column; 22 | margin-left: 1rem; 23 | .group-name { 24 | font-size: 1.6rem; 25 | color: $black0; 26 | } 27 | } 28 | } 29 | } 30 | 31 | &-bd { 32 | margin-top: 1rem; 33 | span { 34 | font-size: 1.4rem; 35 | color: $black0; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /static_source/src/components/Card/SimpleGroup/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './style.scss'; 3 | 4 | function GroupCard (props) { 5 | const { 6 | description, 7 | icon_url, 8 | name, 9 | absolute_url 10 | } = props; 11 | 12 | return ( 13 |
14 | 15 | 19 | 20 |
21 |
22 | {name} 23 |
24 |
25 | {description} 26 |
27 |
28 |
29 | ); 30 | } 31 | 32 | export default GroupCard; 33 | -------------------------------------------------------------------------------- /static_source/src/components/Card/SimpleGroup/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../../common/color.scss'; 2 | 3 | .group-card { 4 | &-simple { 5 | display: flex; 6 | align-items: center; 7 | max-width: 100%; 8 | flex-direction: column; 9 | margin: 1rem 0.5rem 1rem 0.2rem; 10 | color: $black; 11 | } 12 | 13 | &-avatar { 14 | width: 7rem; 15 | height: 7rem; 16 | border-radius: 1rem; 17 | } 18 | &-info { 19 | color: $gray2; 20 | font-size: 0.9rem; 21 | } 22 | &-name { 23 | font-size: 1.6rem; 24 | color: $black0; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /static_source/src/components/Card/Topic/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './style.scss'; 3 | 4 | 5 | function TopicCard (props) { 6 | const { 7 | user, title, updated_at, absolute_url, id, group, created_at, 8 | } = props; 9 | 10 | return ( 11 |
12 |
13 | 14 | {title} 15 | 16 | 17 | ({user.mastodon_account.username} · {updated_at}) 18 | 19 |
20 |
21 | ); 22 | } 23 | 24 | export default TopicCard; 25 | -------------------------------------------------------------------------------- /static_source/src/components/Card/Topic/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../../common/color.scss'; 2 | 3 | .topic-card { 4 | display: flex; 5 | align-items: center; 6 | max-width: 100%; 7 | flex-direction: column; 8 | margin: 0.5rem 0.5rem 0.5rem 0.2rem; 9 | float: left; 10 | color: $gray2; 11 | font-size: 0.9rem; 12 | 13 | &-description { 14 | font-size: 0.8rem; 15 | margin-left: 0.2rem; 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /static_source/src/components/Card/User/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './style.scss'; 3 | 4 | function UserCard (props) { 5 | const {mastodon_site, mastodon_account} = props; 6 | const { 7 | acct, 8 | display_name, 9 | avatar, 10 | header, 11 | note, 12 | url, 13 | followers_count, 14 | following_count, 15 | statuses_count, 16 | } = mastodon_account; 17 | 18 | return ( 19 | 20 | {acct} 21 |
22 |
23 | {display_name} 24 |
25 |
26 | {acct} 27 | @{mastodon_site} 28 |
29 |
30 |
31 | ); 32 | } 33 | 34 | export default UserCard; 35 | -------------------------------------------------------------------------------- /static_source/src/components/Card/User/style.scss: -------------------------------------------------------------------------------- 1 | .user { 2 | display: flex; 3 | align-items: center; 4 | width: 100%; 5 | 6 | &-avatar { 7 | width: 6rem; 8 | height: auto; 9 | margin-right: 1.6rem; 10 | border-radius: 2rem; 11 | } 12 | &-name { 13 | color: #868886; 14 | font-size: 2rem; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /static_source/src/components/Card/index.jsx: -------------------------------------------------------------------------------- 1 | import SimpleGroupCard from './SimpleGroup'; 2 | import GroupCard from './Group'; 3 | import UserCard from './User'; 4 | import TopicCard from './Topic'; 5 | 6 | export { 7 | SimpleGroupCard, 8 | UserCard, 9 | TopicCard, 10 | GroupCard 11 | }; 12 | -------------------------------------------------------------------------------- /static_source/src/components/Comment/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Author from '../Author'; 3 | import Like from '../Like'; 4 | import Quote from '../Quote'; 5 | import axiosInstance from '../../common/axios'; 6 | 7 | import './style.scss'; 8 | 9 | export const ReplyIcon = (props) => { 10 | return 11 | 12 | 13 | } 14 | 15 | function Comment (props) { 16 | const {id, user, created_at, content, liked, like_count, onReply, comment_reply, is_owner} = props; 17 | 18 | const deleteComment = async () => { 19 | await axiosInstance.post(`/group/comment/${id}/delete`, 20 | ).then((res) => { 21 | if (res.status == 200 && res.data && res.data.r === 0) { 22 | document.getElementById(`comment-${id}`).remove(); 23 | } 24 | else { 25 | if (res.status != 200) { 26 | alert("出错了,请联系管理员"); 27 | } 28 | else { 29 | alert(res.data.msg); 30 | } 31 | } 32 | }) 33 | } 34 | 35 | const onDelete = () => { 36 | if (confirm("确定要删除这条评论吗?")) { 37 | deleteComment(); 38 | } 39 | } 40 | 41 | return ( 42 |
43 | 44 |
45 | { 46 | comment_reply && 47 | 48 | } 49 |
50 | {content} 51 |
52 |
53 |
54 | 55 |
onReply(props)} onTouchStart={() => onReply(props)} > 56 | 57 |
58 | {is_owner && 59 |
60 | 删除 61 |
} 62 |
63 |
64 | ); 65 | } 66 | 67 | export default Comment; 68 | -------------------------------------------------------------------------------- /static_source/src/components/Comment/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../common/color.scss"; 2 | .comment { 3 | width: 100%; 4 | margin-bottom: 1.6rem; 5 | transition: all 0.3s ease-in-out; 6 | align-items: center; 7 | 8 | &:hover { 9 | .delete { 10 | display: block; 11 | } 12 | } 13 | 14 | &.highlight { 15 | background-color: rgba($color: $gray, $alpha: 0.3); 16 | } 17 | &-content { 18 | margin-left: 3rem; 19 | } 20 | &-action { 21 | display: flex; 22 | margin-left: 3rem; 23 | margin-top: 1rem; 24 | 25 | .like { 26 | margin-right: 1rem; 27 | } 28 | .reply { 29 | display: flex; 30 | cursor: pointer; 31 | svg { 32 | margin-top: 0.05rem; 33 | fill: $gray; 34 | stroke: none; 35 | } 36 | } 37 | .delete { 38 | color: $gray2; 39 | display: none; 40 | cursor: pointer; 41 | margin-left: auto; 42 | opacity: 0.8; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /static_source/src/components/Editor/editor.scss: -------------------------------------------------------------------------------- 1 | @import "../../common/color.scss"; 2 | 3 | @font-face { 4 | font-family: "Material Icons"; 5 | font-style: normal; 6 | font-weight: 400; 7 | src: url(/static/fonts/MaterialIcons.woff2) format("woff2"); 8 | } 9 | 10 | #editor-wrapper { 11 | background-color: $white2; 12 | width: 68%; 13 | min-width: 68%; 14 | padding: 1.2rem 2.4rem; 15 | min-height: 90vh; 16 | 17 | @media screen and (max-width: 720px) { 18 | width: 100%; 19 | padding: 0; 20 | } 21 | } 22 | .editor { 23 | border-radius: 2px; 24 | padding: 0.8rem; 25 | height: 100%; 26 | 27 | .material-icons { 28 | display: block; 29 | font-size: 1.6rem; 30 | @media screen and (max-width: 540px) { 31 | font-size: 1.4rem; 32 | } 33 | text-align: center; 34 | font-family: "Material Icons"; 35 | letter-spacing: normal; 36 | white-space: nowrap; 37 | -webkit-font-smoothing: antialiased; 38 | } 39 | 40 | &-header { 41 | position: sticky; 42 | top: -0.6rem; 43 | background-color: rgba($white2, 0.8); 44 | z-index: 9; 45 | } 46 | 47 | &-action { 48 | display: flex; 49 | input { 50 | flex: 1 0; 51 | outline: none; 52 | border: none; 53 | font-size: 1.5rem; 54 | @media screen and (max-width: 540px) { 55 | font-size: 1.2rem; 56 | } 57 | color: $black0; 58 | background-color: inherit; 59 | } 60 | button { 61 | float: right; 62 | } 63 | } 64 | 65 | &-toolbar { 66 | margin-top: 0.4rem; 67 | background-color: inherit; 68 | display: grid; 69 | grid-auto-flow: column; 70 | overflow-x: auto; 71 | 72 | width: 100%; 73 | @media (min-width: 960px) { 74 | width: 90%; 75 | } 76 | 77 | @media (min-width: 1200px) { 78 | width: 75%; 79 | } 80 | 81 | modal.link-opened, 82 | modal.image-opened { 83 | position: absolute; 84 | top: 120px; 85 | left: 5%; 86 | width: 90%; 87 | height: 110%; 88 | background-color: rgba($black0, 0.4); 89 | z-index: 10; 90 | padding: 1rem; 91 | border-radius: 6px; 92 | backdrop-filter: blur(1px); 93 | 94 | input { 95 | font-size: 1rem; 96 | padding: 0.5rem; 97 | margin-bottom: 0.6rem; 98 | width: 100%; 99 | border-radius: 0.375rem; 100 | border: 1px $white dashed; 101 | background-color: transparent; 102 | color: $white; 103 | 104 | &::placeholder { 105 | opacity: 0.9; 106 | color: $white; 107 | } 108 | } 109 | 110 | div { 111 | display: flex; 112 | justify-content: flex-end; 113 | align-items: center; 114 | gap: 0.5rem; 115 | 116 | [type="button"] { 117 | background-color: $purple2; 118 | color: #ffffff; 119 | border-radius: 0.375rem; 120 | border: none; 121 | padding: 0.1rem 0.4rem; 122 | 123 | :hover { 124 | background-color: $purple3; 125 | } 126 | } 127 | } 128 | } 129 | } 130 | 131 | &-content { 132 | font-size: 0.9rem; 133 | letter-spacing: 0.8px; 134 | line-height: 1.6; 135 | 136 | img { 137 | cursor: grab; 138 | margin: auto; 139 | max-width: 100%; 140 | min-width: 100px; 141 | min-height: 100px; 142 | 143 | + .editor-button { 144 | align-self: end; 145 | } 146 | } 147 | } 148 | &-button { 149 | color: #d1d5db; 150 | cursor: pointer; 151 | 152 | @media (hover: hover) { 153 | &:hover { 154 | color: #6b7280; 155 | } 156 | } 157 | 158 | &.active { 159 | color: #6b7280; 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /static_source/src/components/Like/index.jsx: -------------------------------------------------------------------------------- 1 | import './style.scss'; 2 | import React, {useState} from 'react'; 3 | import axiosInstance from '../../common/axios'; 4 | 5 | const LikeIcon = (props) => 6 | 7 | 8 | 9 | 10 | function Like (props) { 11 | const {targetId, liked, likeCount, onClick} = props; 12 | const [likedState, setLikedState] = useState({ 13 | liked: liked, 14 | likeCount: likeCount 15 | }); 16 | 17 | const postLike = async () => { 18 | await axiosInstance.post(`/group/comment/${targetId}/like`, {}, 19 | ).then((res) => { 20 | if (res.status == 200 && res.data && res.data.r === 0) { 21 | let nowLiked = Boolean(res.data.data); 22 | setLikedState(s => ({ 23 | liked: nowLiked, 24 | likeCount: s.likeCount + (nowLiked ? 1 : -1) 25 | })); 26 | } 27 | else { 28 | alert(res.data.msg); 29 | } 30 | }).catch((err) => { 31 | if (err.response && err.response.status === 403) { 32 | alert("请先登录"); 33 | } 34 | }) 35 | } 36 | 37 | const onClickLike = () => { 38 | postLike(); 39 | onClick && onClick(); 40 | } 41 | 42 | return ( 43 |
46 | 47 | {likedState.likeCount} 48 |
49 | ); 50 | } 51 | 52 | export default Like; 53 | -------------------------------------------------------------------------------- /static_source/src/components/Like/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../common/color.scss'; 2 | 3 | .like { 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | 8 | svg { 9 | cursor: pointer; 10 | fill: $gray; 11 | } 12 | 13 | &.liked { 14 | svg { 15 | fill: $purple4; 16 | } 17 | } 18 | 19 | &-count { 20 | position: relative; 21 | top: 2px; 22 | margin-left: 5px; 23 | color: $gray2; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /static_source/src/components/Pagination/index.jsx: -------------------------------------------------------------------------------- 1 | import './style.scss'; 2 | import React from 'react'; 3 | import ReactPaginate from 'react-paginate'; 4 | 5 | 6 | function Pagination (props) { 7 | const { 8 | total, 9 | pageSize, 10 | current, 11 | onChange, 12 | } = props; 13 | 14 | 15 | const items = Array.from(Array(total).keys()); 16 | const pageCount = Math.ceil(items.length / pageSize); 17 | 18 | const handlePageClick = (event) => onChange && onChange(event.selected + 1); 19 | if(!total) return (<>) 20 | 21 | return ( 22 | 36 | ); 37 | } 38 | 39 | export default Pagination; 40 | -------------------------------------------------------------------------------- /static_source/src/components/Pagination/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../common/color.scss'; 2 | 3 | /* Style the active class (and buttons on mouse-over) */ 4 | .pagination { 5 | font-size: 1.2rem; 6 | line-height: 1.4; 7 | border-radius: 4px; 8 | display: flex; 9 | justify-content: center; 10 | padding-inline-start: 0 !important; 11 | 12 | li { 13 | display: inline-block; 14 | padding-left: 0; 15 | list-style: none; 16 | @media (hover: hover) and (pointer: fine) { 17 | a:hover { 18 | background-color: $purple2; 19 | color: $white; 20 | } 21 | } 22 | a { 23 | position: relative; 24 | padding: 6px 12px; 25 | text-decoration: none; 26 | color: $gray2; 27 | cursor: pointer; 28 | } 29 | span { 30 | position: relative; 31 | padding: 6px 12px; 32 | text-decoration: none; 33 | color: $gray2; 34 | } 35 | &:first-child { 36 | a { 37 | margin-right: 1rem; 38 | border-bottom-left-radius: 4px; 39 | border-top-left-radius: 4px; 40 | } 41 | span { 42 | padding: 0px; 43 | border-bottom-left-radius: 4px; 44 | border-top-left-radius: 4px; 45 | } 46 | } 47 | &:last-child { 48 | a { 49 | margin-left: 1rem; 50 | border-bottom-right-radius: 4px; 51 | border-top-right-radius: 4px; 52 | } 53 | span { 54 | border-bottom-right-radius: 4px; 55 | margin-right: 0; 56 | border-top-right-radius: 4px; 57 | } 58 | } 59 | } 60 | li.active { 61 | a { 62 | color: $purple2; 63 | &:hover { 64 | background-color: $white2; 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /static_source/src/components/Quote/index.jsx: -------------------------------------------------------------------------------- 1 | import './style.scss'; 2 | import React from 'react'; 3 | 4 | function Quote (props) { 5 | const {comment, onRemove} = props; 6 | return ( 7 |
8 |
9 | {comment.content} 10 |
11 |
12 | {comment.user && comment.user.username} 13 |
14 |
15 | ); 16 | } 17 | 18 | export default Quote; 19 | 20 | -------------------------------------------------------------------------------- /static_source/src/components/Quote/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../common/color.scss'; 2 | 3 | .comment-quote { 4 | padding: 0 0.4rem; 5 | display: flex; 6 | margin-right: 0.5rem; 7 | margin-bottom: 0.2rem; 8 | border-left: 2px solid $gray; 9 | 10 | // svg { 11 | // min-width: 2rem; 12 | // transform: rotate(180deg); 13 | // fill: $gray; 14 | // stroke: none; 15 | // } 16 | 17 | &-author { 18 | } 19 | 20 | &-content { 21 | padding: 5px; 22 | font-size: 0.9rem; 23 | line-break: anywhere; 24 | padding: 3px; 25 | opacity: 0.65; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /static_source/src/components/ReplyForm/index.jsx: -------------------------------------------------------------------------------- 1 | import './style.scss'; 2 | import React, {forwardRef, useEffect, useState} from 'react'; 3 | import Quote from '../Quote'; 4 | import axiosInstance from '../../common/axios'; 5 | import {FediIcon, SendIcon} from '../../common/utils'; 6 | 7 | 8 | const ReplyForm = forwardRef((props, ref) => { 9 | const {topicId} = props; 10 | const [replyState, setReplyState] = useState({ 11 | sending: false, 12 | share_to_mastodon: true, 13 | quote: props.replyComment, 14 | }); 15 | 16 | useEffect(() => { 17 | setReplyState({ 18 | ...replyState, 19 | quote: props.replyComment, 20 | }) 21 | }, [props.replyComment]); 22 | 23 | 24 | const postComment = async (params) => { 25 | setReplyState({ 26 | ...replyState, 27 | sending: true, 28 | }); 29 | await axiosInstance.post(`/group/topic/${topicId}/`, params, 30 | ).then((res) => { 31 | setReplyState({ 32 | ...replyState, 33 | sending: false, 34 | }); 35 | 36 | if (res.status == 200) { 37 | if (res.data.r === 0) { 38 | location.reload(); 39 | } 40 | else { 41 | alert(res.data.msg); 42 | } 43 | } 44 | else { 45 | alert('出错啦,请稍后再试或联系管理员'); 46 | } 47 | }) 48 | } 49 | 50 | const onSubmit = () => { 51 | let content = ref.current && ref.current.querySelector('[contentEditable]').innerText.replaceAll('\n\n', '\n'); 52 | if (!content) { 53 | alert('请输入一段文字再提交吧'); 54 | return 55 | } 56 | postComment({ 57 | id: topicId, 58 | content: content, 59 | comment_reply: replyState.quote?.id, 60 | share_to_mastodon: replyState.share_to_mastodon, 61 | }); 62 | } 63 | 64 | const toggleShareToMastodon = () => { 65 | setReplyState({ 66 | ...replyState, 67 | share_to_mastodon: !replyState.share_to_mastodon 68 | }) 69 | } 70 | 71 | return ( 72 |
73 | { 74 | replyState.quote && 75 | } 76 |
{ 77 | if (e.currentTarget.textContent === '' && (e.key === 'Backspace' || e.key === 'Delete')) { 78 | setReplyState( 79 | { 80 | ...replyState, 81 | quote: null, 82 | } 83 | ) 84 | } 85 | }} /> 86 |
87 |
88 | 89 |
90 |
91 | 92 |
93 |
94 |
95 | ); 96 | }); 97 | 98 | export default ReplyForm; 99 | -------------------------------------------------------------------------------- /static_source/src/components/ReplyForm/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../common/color.scss'; 2 | .reply { 3 | &-form { 4 | display: flex; 5 | padding: 1rem; 6 | flex-direction: column; 7 | } 8 | 9 | &-content { 10 | min-height: 5rem; 11 | font-size: 0.9rem; 12 | &[contenteditable] { 13 | &:focus { 14 | outline: none; 15 | } 16 | &:empty:before { 17 | content: attr(placeholder); 18 | pointer-events: none; 19 | color: rgba($gray2, 0.7); 20 | display: block; 21 | } 22 | } 23 | } 24 | 25 | &-action { 26 | display: flex; 27 | align-self: flex-end; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /static_source/src/components/Sidebar/GroupHome/index.jsx: -------------------------------------------------------------------------------- 1 | import './style.scss'; 2 | import React from 'react'; 3 | 4 | function GroupSidebar (props) { 5 | const {last_join_users, operations} = props; 6 | const accounts = last_join_users.map(u => u.user); 7 | 8 | return ( 9 |
10 | 最近加入 11 |
12 | { 13 | accounts ? accounts.map((account) => 14 | 15 | 16 |
17 | {account.mastodon_account.display_name} 18 |
19 |
) : '这里冷清清的' 20 | } 21 |
22 | {operations ? ( 23 |
24 | { 25 | operations.map(o => {o[1]} 26 | ) 27 | } 28 |
29 | ) : null} 30 |
31 | ); 32 | } 33 | 34 | export default GroupSidebar; 35 | -------------------------------------------------------------------------------- /static_source/src/components/Sidebar/GroupHome/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../../common/color.scss'; 2 | @import '../../../common/utils.scss'; 3 | @import '../../../common/sidebar.scss'; 4 | 5 | .sidebar { 6 | margin-top: 2rem; 7 | 8 | .latest-join { 9 | margin-top: 1rem; 10 | display: inline-grid; 11 | grid-template-columns: repeat(4, 1fr); 12 | grid-gap: 0.6rem; 13 | place-items: center; 14 | 15 | .account { 16 | display: flex; 17 | align-items: center; 18 | flex-direction: column; 19 | 20 | &-name { 21 | margin-top: 0.2rem; 22 | text-align: center; 23 | line-height: 1.1rem; 24 | @include line-clamp(2); 25 | } 26 | &-avatar { 27 | width: 2.2rem; 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /static_source/src/components/Sidebar/Home/index.jsx: -------------------------------------------------------------------------------- 1 | import './style.scss'; 2 | import React from 'react'; 3 | 4 | function HomeSidebar (props) { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | 12 | export default HomeSidebar; 13 | 14 | -------------------------------------------------------------------------------- /static_source/src/components/Sidebar/Home/style.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/static_source/src/components/Sidebar/Home/style.scss -------------------------------------------------------------------------------- /static_source/src/components/Sidebar/Topic/index.jsx: -------------------------------------------------------------------------------- 1 | import './style.scss'; 2 | import React from 'react'; 3 | import SimpleGroupCard from '../../Card/SimpleGroup'; 4 | import TopicCard from '../../Card/Topic'; 5 | 6 | function TopicSidebar (props) { 7 | const {group, last_topics} = props; 8 | return ( 9 |
10 |
11 | 12 |
13 | 正在发生 14 |
15 | { 16 | last_topics ? last_topics.map((topic) => ) : '暂时无事发生,去看看别的组吧' 17 | } 18 |
19 |
20 | ); 21 | } 22 | 23 | 24 | export default TopicSidebar; 25 | -------------------------------------------------------------------------------- /static_source/src/components/Sidebar/Topic/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../../common/color.scss'; 2 | @import '../../../common/sidebar.scss'; 3 | 4 | .sidebar { 5 | padding: 0 0.5rem ; 6 | &-title { 7 | font-size: 1.6rem; 8 | margin-top: 2rem; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /static_source/src/components/Sidebar/index.jsx: -------------------------------------------------------------------------------- 1 | import GroupSidebar from './GroupHome'; 2 | import HomeSidebar from './Home'; 3 | import TopicSidebar from './Topic'; 4 | 5 | export { 6 | GroupSidebar, 7 | HomeSidebar, 8 | TopicSidebar 9 | }; 10 | -------------------------------------------------------------------------------- /static_source/src/components/index.js: -------------------------------------------------------------------------------- 1 | import Author from "./Author"; 2 | import { GroupCard, SimpleGroupCard, TopicCard, UserCard } from "./Card"; 3 | import Comment from "./Comment"; 4 | import Editor from "./Editor"; 5 | import Like from "./Like"; 6 | import Nav from "./Nav"; 7 | import Pagination from "./Pagination"; 8 | import Quote from "./Quote"; 9 | import ReplyForm from "./ReplyForm"; 10 | import { GroupSidebar, HomeSidebar, TopicSidebar } from "./Sidebar"; 11 | 12 | export { 13 | Author, 14 | GroupCard, 15 | SimpleGroupCard, 16 | TopicCard, 17 | UserCard, 18 | Comment, 19 | Editor, 20 | Like, 21 | Nav, 22 | Pagination, 23 | Quote, 24 | ReplyForm, 25 | GroupSidebar, 26 | HomeSidebar, 27 | TopicSidebar, 28 | }; 29 | -------------------------------------------------------------------------------- /static_source/src/hypernova.js: -------------------------------------------------------------------------------- 1 | import hypernova from 'hypernova/server'; 2 | import { renderReact } from 'hypernova-react'; 3 | import express from 'express'; 4 | import * as App from './App'; 5 | 6 | require = require('esm')(module); 7 | 8 | let config = { 9 | devMode: process.env.NODE_ENV !== 'production', 10 | port: 3030, 11 | getComponent(name, _context) { 12 | if (name.indexOf('C.') !== 0) { 13 | return null; 14 | } 15 | 16 | try { 17 | const componentName = name.split('.').slice(-1)[0]; 18 | const Containers = App.default; 19 | const Component = Containers[componentName]; 20 | return renderReact(componentName, Component); 21 | } catch (e) { 22 | console.error(e); 23 | 24 | return null; 25 | } 26 | }, 27 | createApplication() { 28 | const app = express(); 29 | // cors 30 | app.use(function (req, res, next) { 31 | res.header('Access-Control-Allow-Origin', '*'); 32 | res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); 33 | next(); 34 | }); 35 | return app; 36 | }, 37 | }; 38 | 39 | hypernova(config); 40 | -------------------------------------------------------------------------------- /static_source/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOMClient from 'react-dom'; 3 | import * as C from './App'; 4 | import './index.scss'; 5 | 6 | window.React = React; 7 | window.ReactDOM = ReactDOMClient; 8 | window.C = C; 9 | -------------------------------------------------------------------------------- /static_source/src/index.scss: -------------------------------------------------------------------------------- 1 | @import './common/color.scss'; 2 | @import './reset.css'; 3 | 4 | body { 5 | margin: 0; 6 | padding: 0; 7 | box-sizing: border-box; 8 | background: $white; 9 | font-family: system, -apple-system, 'PingfangSC', 'Hiragino Sans GB', Microsoft YaHei, Roboto, Helvetica, Arial, sans-serif; 10 | font-size: 18px; 11 | @media screen and (max-width: 720px) { 12 | font-size: 15px; 13 | } 14 | } 15 | 16 | .button { 17 | color: $white; 18 | height: 2rem; 19 | padding: 0 0.6rem; 20 | min-width: fit-content; 21 | border-radius: 0.5rem; 22 | background-color: $purple; 23 | font-size: 1rem; 24 | text-align: center; 25 | line-height: 2rem; 26 | cursor: pointer; 27 | border: 1px solid $purple2; 28 | &:hover { 29 | color: $purple4; 30 | } 31 | } 32 | 33 | .share-to-mastodon { 34 | margin-right: 0.5rem; 35 | cursor: pointer; 36 | svg { 37 | fill: $gray; 38 | } 39 | &.yes { 40 | svg { 41 | fill: $purple4; 42 | } 43 | } 44 | } 45 | 46 | 47 | .submit { 48 | cursor: pointer; 49 | color: $purple4; 50 | border-radius: 8px; 51 | 52 | padding: 0.2rem 0.4rem; 53 | outline: none; 54 | display: flex; 55 | align-items: center; 56 | svg { 57 | fill: $purple4; 58 | } 59 | 60 | transition: all 0.2s ease-in-out; 61 | &:hover, 62 | &:focus, 63 | &:active { 64 | background-color: $purple4; 65 | svg { 66 | fill: $white; 67 | } 68 | } 69 | } 70 | 71 | a { 72 | color: $gray2; 73 | text-decoration: none; 74 | &:hover { 75 | color: $purple4; 76 | } 77 | } 78 | 79 | #content { 80 | display: flex; 81 | max-width: min(95%, 1200px); 82 | margin: auto; 83 | margin-top: 4rem; 84 | margin-bottom: 2rem; 85 | 86 | @media screen and (max-width: 540px) { 87 | max-width: 100%; 88 | margin-top: 2.4rem; 89 | margin-bottom: 0; 90 | min-height: calc(100vh - 2.4rem); 91 | } 92 | 93 | justify-content: center; 94 | line-height: 1.6; 95 | font-size: 0.9rem; 96 | color: $black0; 97 | 98 | main { 99 | background-color: $white2; 100 | @media screen and (max-width: 720px) { 101 | width: 100%; 102 | padding: 0.8rem 1rem; 103 | } 104 | width: 68%; 105 | min-width: 68%; 106 | padding: 1.2rem 2.4rem; 107 | } 108 | 109 | sidebar { 110 | flex: auto; 111 | margin: 0 2rem; 112 | background-color: $white2; 113 | @media screen and (max-width: 540px) { 114 | display: none; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /static_source/src/pages/Group/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../common/color.scss'; 2 | @import '../../common/utils.scss'; 3 | 4 | .group { 5 | display: flex; 6 | align-items: flex-start; 7 | width: 100%; 8 | flex-flow: column; 9 | font-size: 0.9rem; 10 | 11 | .topics { 12 | width: 100%; 13 | 14 | &-hd { 15 | display: flex; 16 | justify-content: space-between; 17 | margin-bottom: 1rem; 18 | font-size: 1.3rem; 19 | 20 | } 21 | 22 | table { 23 | width: 100%; 24 | border-collapse: collapse; 25 | border-spacing: 0; 26 | border: none; 27 | 28 | tbody { 29 | tr { 30 | td { 31 | color: $gray2; 32 | text-align: center; 33 | padding: 0.3rem 0.05rem; 34 | font-size: 0.7rem; 35 | 36 | a { 37 | @include line-clamp(2, 2); 38 | } 39 | } 40 | 41 | .topic-title { 42 | font-size: 0.9rem; 43 | display: flex; 44 | flex-direction: row; 45 | align-items: flex-start; 46 | text-align: left; 47 | 48 | svg { 49 | margin-top: 0.3rem; 50 | margin-right: 0.3rem; 51 | min-width: 1.2rem; 52 | max-width: 1.2rem; 53 | } 54 | } 55 | 56 | th:last-child, td:last-child { 57 | text-align: end; 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /static_source/src/pages/Topic/index.jsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState, useRef} from 'react'; 2 | import DOMPurify from 'isomorphic-dompurify'; 3 | import Author from '../../components/Author'; 4 | import Comment from '../../components/Comment'; 5 | import ReplyForm from '../../components/ReplyForm'; 6 | import Pagination from '../../components/Pagination'; 7 | import axiosInstance from '../../common/axios'; 8 | import {useIsomorphicLayoutEffect} from '../../common/utils'; 9 | import './style.scss'; 10 | 11 | function Topic (props) { 12 | const [comments, setComments] = useState(props.comments || []); 13 | const [replyComment, setReplyComment] = useState(null); 14 | const [page, setPage] = useState(props.page); 15 | const {user, updated_at, id, title, html_content, group} = props; 16 | 17 | const replyFormRef = useRef(null); 18 | 19 | const fetchComments = async () => { 20 | await axiosInstance.get(`/group/topic/${id}/comments`, { 21 | params: { 22 | page: page, 23 | } 24 | }).then((res) => { 25 | if (res.status == 200) { 26 | if (res.data.r === 0) { 27 | setComments(res.data.data); 28 | window.scroll(0, document.querySelector('.comments-wrapper').offsetTop - 100, { 29 | behavior: 'smooth' 30 | }); 31 | } 32 | else { 33 | alert(res.data.msg); 34 | } 35 | } 36 | }) 37 | } 38 | 39 | const firstUpdate = useRef(true); 40 | useIsomorphicLayoutEffect(() => { 41 | if (firstUpdate.current) { 42 | firstUpdate.current = false; 43 | } else { 44 | fetchComments(); 45 | window.history.pushState(null, null, `?page=${page}`); 46 | } 47 | }, [page]); 48 | 49 | useEffect(() => { 50 | if (replyComment && replyFormRef.current) { 51 | replyFormRef.current.querySelector('[contenteditable]').focus(); 52 | } 53 | }, [replyComment]); 54 | 55 | useIsomorphicLayoutEffect(() => { 56 | // get comment id from url query ,active and scroll to it 57 | const urlParams = new URLSearchParams(window.location.search); 58 | const commentId = urlParams.get('comment_id'); 59 | 60 | if (commentId) { 61 | const comment = document.getElementById(`comment-${commentId}`); 62 | if (comment) { 63 | window.scroll(0, comment.offsetTop - 100, { 64 | behavior: 'smooth' 65 | }); 66 | comment.classList.add('highlight'); 67 | setTimeout(() => { 68 | comment.classList.remove('highlight'); 69 | }, 600); 70 | } 71 | } 72 | }, []); 73 | 74 | const onReply = (comment) => setReplyComment(comment); 75 | 76 | return ( 77 |
78 |
79 | {title} 80 |
81 | 82 |
88 | 89 |
90 |
91 |
92 | {comments.map((comment, index) => 93 | )} 94 |
95 | setPage(page)} 100 | /> 101 |
102 |
103 | 104 |
105 |
106 | ); 107 | } 108 | 109 | 110 | export default Topic; 111 | 112 | -------------------------------------------------------------------------------- /static_source/src/pages/Topic/style.scss: -------------------------------------------------------------------------------- 1 | @import "../../common/color.scss"; 2 | @import "../../common/utils.scss"; 3 | 4 | .topic { 5 | display: flex; 6 | align-items: flex-start; 7 | width: 100%; 8 | flex-flow: column; 9 | 10 | &-content { 11 | color: $black; 12 | padding-left: 3rem; 13 | width: 100%; 14 | letter-spacing: 0.8px; 15 | 16 | img { 17 | display: block; 18 | margin: auto; 19 | max-width: 100%; 20 | min-width: 100px; 21 | min-height: 100px; 22 | } 23 | } 24 | 25 | .title { 26 | @include line-clamp(2, 3); 27 | max-width: 100%; 28 | font-size: 1.8rem; 29 | color: $black; 30 | margin-bottom: 1rem; 31 | } 32 | 33 | .comments { 34 | &-wrapper { 35 | width: 100%; 36 | } 37 | display: flex; 38 | flex-flow: column; 39 | align-items: center; 40 | } 41 | .reply-form-wrapper { 42 | margin: 2rem 0; 43 | background-color: white; 44 | border-radius: 4px; 45 | width: 100%; 46 | box-shadow: #0000001f 0 2px 8px; 47 | color: $black0; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /static_source/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import Group from './Group'; 2 | import Topic from './Topic'; 3 | 4 | export { 5 | Group, 6 | Topic 7 | }; 8 | -------------------------------------------------------------------------------- /static_source/src/reset.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, 6 | *:before, 7 | *:after { 8 | box-sizing: inherit; 9 | } 10 | 11 | img, 12 | object, 13 | embed { 14 | max-width: 100%; 15 | } 16 | 17 | 18 | li+li { 19 | margin-top: 0.25rem; 20 | } 21 | 22 | blockquote { 23 | border-left: 2px solid #ddd; 24 | margin-left: 0; 25 | margin-right: 0; 26 | padding-left: 10px; 27 | color: #aaa; 28 | } 29 | 30 | mark { 31 | background-color: #5a47a533; 32 | } 33 | 34 | blockquote, 35 | q { 36 | quotes: none; 37 | } 38 | 39 | blockquote:before, 40 | blockquote:after, 41 | q:before, 42 | q:after { 43 | content: ''; 44 | content: none; 45 | } 46 | 47 | a { 48 | margin: 0; 49 | padding: 0; 50 | font-size: 100%; 51 | vertical-align: baseline; 52 | background: transparent; 53 | color: inherit; 54 | font-family: inherit; 55 | font-size: inherit; 56 | text-decoration: none; 57 | } 58 | 59 | del { 60 | text-decoration: line-through; 61 | } 62 | 63 | abbr[title], 64 | dfn[title] { 65 | border-bottom: 1px dotted #000; 66 | cursor: help; 67 | } 68 | 69 | /* tables still need cellspacing="0" in the markup */ 70 | table { 71 | border-collapse: collapse; 72 | border-spacing: 0; 73 | } 74 | 75 | th { 76 | font-weight: 500; 77 | vertical-align: bottom; 78 | } 79 | 80 | td { 81 | font-weight: normal; 82 | vertical-align: top; 83 | } 84 | 85 | hr { 86 | display: block; 87 | height: 1px; 88 | border: 0; 89 | border-top: 1px solid #ccc; 90 | margin: 1em 0; 91 | padding: 0; 92 | } 93 | 94 | input, 95 | select { 96 | vertical-align: middle; 97 | } 98 | 99 | pre { 100 | white-space: pre-wrap; 101 | word-wrap: break-word; 102 | -webkit-overflow-scrolling: touch; 103 | overflow-x: auto; 104 | max-width: 100%; 105 | min-width: 100px; 106 | background: #f8f8f8; 107 | color: #6a5c89; 108 | padding: 20px; 109 | box-shadow: 0 0 8px #6a5c8942; 110 | 111 | } 112 | 113 | input[type="radio"] { 114 | vertical-align: text-bottom; 115 | } 116 | 117 | input[type="checkbox"] { 118 | vertical-align: bottom; 119 | } 120 | 121 | select, 122 | input, 123 | textarea { 124 | font: 99% sans-serif; 125 | } 126 | 127 | table { 128 | font-size: inherit; 129 | font: 100%; 130 | } 131 | 132 | small { 133 | font-size: 85%; 134 | } 135 | 136 | strong { 137 | font-weight: bold; 138 | } 139 | 140 | td, 141 | td img { 142 | vertical-align: top; 143 | } 144 | 145 | /* Make sure sup and sub don't mess with your line-heights https://gist.github.com/413930 */ 146 | sub, 147 | sup { 148 | font-size: 75%; 149 | line-height: 0; 150 | position: relative; 151 | } 152 | 153 | sup { 154 | top: -0.5em; 155 | } 156 | 157 | sub { 158 | bottom: -0.25em; 159 | } 160 | 161 | /* standardize any monospaced elements */ 162 | pre, 163 | code, 164 | kbd, 165 | samp { 166 | font-family: monospace, sans-serif; 167 | } 168 | 169 | 170 | code { 171 | padding: 0.2em 0.4em; 172 | margin: 0; 173 | font-size: 85%; 174 | white-space: break-spaces; 175 | background-color: rgba(175, 184, 193, 0.2); 176 | border-radius: 6px; 177 | } 178 | 179 | /* hand cursor on clickable elements */ 180 | .clickable, 181 | label, 182 | input[type=button], 183 | input[type=submit], 184 | input[type=file], 185 | button { 186 | cursor: pointer; 187 | } 188 | 189 | /* Webkit browsers add a 2px margin outside the chrome of form elements */ 190 | button, 191 | input, 192 | select, 193 | textarea { 194 | margin: 0; 195 | } 196 | 197 | /* make buttons play nice in IE */ 198 | button, 199 | input[type=button] { 200 | width: auto; 201 | overflow: visible; 202 | } 203 | 204 | /* let's clear some floats */ 205 | .clearfix:before, 206 | .clearfix:after { 207 | content: "\0020"; 208 | display: block; 209 | height: 0; 210 | overflow: hidden; 211 | } 212 | 213 | .clearfix:after { 214 | clear: both; 215 | } 216 | 217 | .clearfix { 218 | zoom: 1; 219 | } 220 | 221 | .divide { 222 | height: 0; 223 | width: 100%; 224 | margin: 24px 0 24px; 225 | border-top: solid 1px #ccc; 226 | } 227 | 228 | .p { 229 | white-space: pre-wrap; 230 | } 231 | -------------------------------------------------------------------------------- /static_source/vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig } from "vite"; 3 | import react from "@vitejs/plugin-react"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | define: { 9 | "process.env": process.env, 10 | }, 11 | terserOptions: { 12 | // 生产环境下移除console 13 | compress: { 14 | drop_console: true, 15 | drop_debugger: true 16 | } 17 | }, 18 | build: { 19 | emptyOutDir: true, 20 | rollupOptions: { 21 | input: { 22 | group: resolve(__dirname, "src/index.jsx"), 23 | editor: resolve(__dirname, "src/components/Editor/editor.jsx"), 24 | }, 25 | output: { 26 | dir: "../common/static/react/", 27 | entryFileNames: "[name].js", 28 | chunkFileNames: "[name].js", 29 | assetFileNames: "[name].[ext]", 30 | }, 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/users/__init__.py -------------------------------------------------------------------------------- /users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import * 3 | 4 | 5 | admin.site.register(User) 6 | admin.site.register(Report) -------------------------------------------------------------------------------- /users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UsersConfig(AppConfig): 5 | name = "users" 6 | -------------------------------------------------------------------------------- /users/data.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import reverse, redirect, render, get_object_or_404 2 | from django.http import HttpResponseBadRequest, HttpResponse 3 | from django.contrib.auth.decorators import login_required 4 | from django.contrib import auth 5 | from django.contrib.auth import authenticate 6 | from django.core.paginator import Paginator 7 | from django.utils.translation import gettext_lazy as _ 8 | from django.core.exceptions import ObjectDoesNotExist 9 | from django.db.models import Count 10 | from .models import User, Report 11 | from .forms import ReportForm 12 | from mastodon.api import * 13 | from mastodon import mastodon_request_included 14 | from common.config import * 15 | from common.utils import PageLinksGenerator 16 | from mastodon.models import MastodonApplication 17 | from mastodon.api import verify_account 18 | from django.conf import settings 19 | from urllib.parse import quote 20 | import django_rq 21 | from .account import * 22 | from .tasks import * 23 | from datetime import timedelta 24 | from django.utils import timezone 25 | import json 26 | from django.contrib import messages 27 | 28 | 29 | @mastodon_request_included 30 | @login_required 31 | def data(request): 32 | return render( 33 | request, 34 | "users/data.html", 35 | { 36 | "allow_any_site": settings.MASTODON_ALLOW_ANY_SITE, 37 | "import_status": request.user.get_preference().import_status, 38 | "export_status": request.user.get_preference().export_status, 39 | }, 40 | ) 41 | 42 | 43 | @login_required 44 | def data_import_status(request): 45 | return render( 46 | request, 47 | "users/data_import_status.html", 48 | { 49 | "import_status": request.user.get_preference().import_status, 50 | }, 51 | ) 52 | 53 | 54 | @mastodon_request_included 55 | @login_required 56 | def export_reviews(request): 57 | if request.method != "POST": 58 | return redirect(reverse("users:data")) 59 | return render(request, "users/data.html") 60 | 61 | 62 | @mastodon_request_included 63 | @login_required 64 | def export_marks(request): 65 | if request.method == "POST": 66 | if not request.user.preference.export_status.get("marks_pending"): 67 | django_rq.get_queue("export").enqueue(export_marks_task, request.user) 68 | request.user.preference.export_status["marks_pending"] = True 69 | request.user.preference.save() 70 | messages.add_message(request, messages.INFO, _("导出已开始。")) 71 | return redirect(reverse("users:data")) 72 | else: 73 | try: 74 | with open(request.user.preference.export_status["marks_file"], "rb") as fh: 75 | response = HttpResponse( 76 | fh.read(), content_type="application/vnd.ms-excel" 77 | ) 78 | response["Content-Disposition"] = 'attachment;filename="marks.xlsx"' 79 | return response 80 | except Exception: 81 | messages.add_message(request, messages.ERROR, _("导出文件已过期,请重新导出")) 82 | return redirect(reverse("users:data")) 83 | 84 | 85 | @login_required 86 | def sync_mastodon(request): 87 | if request.method == "POST": 88 | django_rq.get_queue("mastodon").enqueue( 89 | refresh_mastodon_data_task, request.user 90 | ) 91 | messages.add_message(request, messages.INFO, _("同步已开始。")) 92 | return redirect(reverse("users:data")) -------------------------------------------------------------------------------- /users/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from .models import Report 3 | from django.utils.translation import gettext_lazy as _ 4 | from common.forms import PreviewImageInput 5 | 6 | 7 | class ReportForm(forms.ModelForm): 8 | class Meta: 9 | model = Report 10 | fields = [ 11 | "reported_user", 12 | "image", 13 | "message", 14 | ] 15 | widgets = { 16 | "message": forms.Textarea(attrs={"placeholder": _("详情")}), 17 | "image": PreviewImageInput() 18 | # 'reported_user': forms.TextInput(), 19 | } 20 | labels = {"reported_user": _("举报的用户"), "image": _("相关证据"), "message": _("详情")} 21 | -------------------------------------------------------------------------------- /users/management/commands/backfill_mastodon.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from users.models import User 3 | from django.contrib.sessions.models import Session 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Backfill Mastodon data if missing" 8 | 9 | def handle(self, *args, **options): 10 | for session in Session.objects.order_by("-expire_date"): 11 | uid = session.get_decoded().get("_auth_user_id") 12 | token = session.get_decoded().get("oauth_token") 13 | if uid and token: 14 | user = User.objects.get(pk=uid) 15 | if user.mastodon_token: 16 | print(f"skip {user}") 17 | continue 18 | user.mastodon_token = token 19 | user.refresh_mastodon_data() 20 | user.save() 21 | print(f"Refreshed {user}") 22 | -------------------------------------------------------------------------------- /users/management/commands/disable_user.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from users.models import User 3 | from datetime import timedelta 4 | from django.utils import timezone 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "disable user" 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument("id", type=int, help="user id") 12 | 13 | def handle(self, *args, **options): 14 | h = int(options["id"]) 15 | u = User.objects.get(id=h) 16 | u.username = "(duplicated)" + u.username 17 | u.is_active = False 18 | u.save() 19 | print(f"{u} updated") 20 | -------------------------------------------------------------------------------- /users/management/commands/refresh_following.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from users.models import User 3 | from datetime import timedelta 4 | from django.utils import timezone 5 | from tqdm import tqdm 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Refresh following data for all users" 10 | 11 | def handle(self, *args, **options): 12 | count = 0 13 | for user in tqdm(User.objects.all()): 14 | user.following = user.get_following_ids() 15 | if user.following: 16 | count += 1 17 | user.save(update_fields=["following"]) 18 | 19 | print(f"{count} users updated") 20 | -------------------------------------------------------------------------------- /users/management/commands/refresh_mastodon.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from users.models import User 3 | from datetime import timedelta 4 | from django.utils import timezone 5 | from tqdm import tqdm 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Refresh Mastodon data for all users if not updated in last 24h" 10 | 11 | def handle(self, *args, **options): 12 | count = 0 13 | for user in tqdm( 14 | User.objects.filter( 15 | mastodon_last_refresh__lt=timezone.now() - timedelta(hours=24), 16 | is_active=True, 17 | ) 18 | ): 19 | if user.mastodon_token or user.mastodon_refresh_token: 20 | tqdm.write(f"Refreshing {user}") 21 | if user.refresh_mastodon_data(): 22 | tqdm.write(f"Refreshed {user}") 23 | count += 1 24 | else: 25 | tqdm.write(f"Refresh failed for {user}") 26 | user.save() 27 | else: 28 | tqdm.write(f"Missing token for {user}") 29 | 30 | print(f"{count} users updated") 31 | -------------------------------------------------------------------------------- /users/migrations/0002_alter_user_username.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-01-29 03:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("users", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="user", 15 | name="username", 16 | field=models.CharField( 17 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 18 | max_length=150, 19 | verbose_name="username", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xasahi/neogroup/da9c66430532f527c5735b3b7960a7b2e4649ba6/users/migrations/__init__.py -------------------------------------------------------------------------------- /users/tasks.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import reverse, redirect, render, get_object_or_404 2 | from django.http import HttpResponseBadRequest, HttpResponse 3 | from django.contrib.auth.decorators import login_required 4 | from django.contrib import auth 5 | from django.contrib.auth import authenticate 6 | from django.core.paginator import Paginator 7 | from django.utils.translation import gettext_lazy as _ 8 | from django.core.exceptions import ObjectDoesNotExist 9 | from django.db.models import Count 10 | from .models import User, Report 11 | from .forms import ReportForm 12 | from mastodon.api import * 13 | from mastodon import mastodon_request_included 14 | from common.config import * 15 | from common.utils import PageLinksGenerator 16 | from mastodon.models import MastodonApplication 17 | from django.conf import settings 18 | from urllib.parse import quote 19 | from common.utils import GenerateDateUUIDMediaFilePath 20 | from datetime import datetime 21 | import os 22 | 23 | 24 | def refresh_mastodon_data_task(user, token=None): 25 | if token: 26 | user.mastodon_token = token 27 | if user.refresh_mastodon_data(): 28 | user.save() 29 | print(f"{user} mastodon data refreshed") 30 | else: 31 | print(f"{user} mastodon data refresh failed") 32 | -------------------------------------------------------------------------------- /users/templates/users/data_import_status.html: -------------------------------------------------------------------------------- 1 | {% if import_status.douban_pending == 2 %} 2 | 正在等待 3 | {% elif import_status.douban_pending == 1 %} 4 |
5 | 正在导入 6 | {% if import_status.douban_total %} 7 |
8 | 9 | 共{{ import_status.douban_total }}篇,目前已处理{{ import_status.douban_processed }}篇,其中已存在{{ import_status.douban_skipped }}篇,新增{{ import_status.douban_imported }}篇 10 | {% endif %} 11 |
12 | {% elif import_status.douban_file %} 13 | 上次结果 14 | 共计{{ import_status.douban_total }}篇,处理{{ import_status.douban_processed }}篇,其中已存在{{ import_status.douban_skipped }}篇,新增{{ import_status.douban_imported }}篇 15 | {% endif %} 16 | -------------------------------------------------------------------------------- /users/templates/users/home_anonymous.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load i18n %} 3 | 4 | 5 | 6 | 7 | 8 | {{ site_name }} - {{ username }}@{{ site }} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Mastodon homepage 17 | 18 | -------------------------------------------------------------------------------- /users/templates/users/login.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load static %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {{ site_name }} - {% trans '登录' %} 14 | {% include "libs/common.html" with jquery=1 %} 15 | 16 | 17 | 18 | 19 | 20 | 54 | 55 | 56 |
57 | 58 |
59 | {% if user.is_authenticated %} 60 | {% trans '前往首页' %} 61 | {% else %} 62 |
63 | {% if allow_any_site %} 64 | 69 | 70 | 71 | {% else %} 72 | 77 | 78 | {% endif %} 79 |
80 | {% endif %} 81 |
网页加载超时,请检查网络(或科学上网)设置。
82 |
83 | 84 | 85 | 86 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /users/templates/users/manage_report.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load i18n %} 3 | {% load admin_url %} 4 | {% load mastodon %} 5 | {% load oauth_token %} 6 | {% load truncate %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | {{ site_name }} - {% trans '管理举报' %} 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 | {% include "partial/_navbar.html" %} 23 | 24 |
25 |
26 |
27 | 28 | {% for report in reports %} 29 |
30 | {{ report.submit_user.username }} 31 | {% trans '举报了' %} 32 | {{ report.reported_user.username }} 33 | @{{ report.submitted_time }} 34 | 35 | {% if report.image %} 36 | 37 | 38 | {% endif %} 39 | 40 |
41 | {% endfor %} 42 | 43 |
44 | 45 |
46 |
47 |
48 | {% include "partial/_footer.html" %} 49 |
50 | 51 | 52 | 53 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /users/templates/users/register.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load static %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {{ site_name }} - {% trans '注册' %} 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 |
21 |

欢迎来到{{ site_name }}!

22 |

23 | {{ site_name }}还在不断完善中,丰富的内容需要大家共同创造。 24 | 试图添加垃圾数据,将会受到严肃处理。 25 | {{ site_name }}继承了联邦宇宙的用户关系,比如您在联邦宇宙屏蔽了某人,那您将不会在公共区域看到TA的痕迹。 26 |

27 |

28 | 此外,{{ site_name }}现处于测试阶段,疏漏在所难免,请妥善备份您的数据。 29 | 使用过程中遇到的问题或者错误欢迎向维护者提出。感谢理解和支持! 30 |

31 |
32 | 33 |
34 | 35 |
36 |
37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /users/templates/users/report.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load i18n %} 3 | {% load admin_url %} 4 | {% load mastodon %} 5 | {% load oauth_token %} 6 | {% load truncate %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | {{ site_name }} - {% trans '举报用户' %} 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 | {% include "partial/_navbar.html" %} 23 | 24 |
25 |
26 |
27 |
28 | {% csrf_token %} 29 | {{ form }} 30 | 31 |
32 |
33 | 34 |
35 |
36 |
37 | {% include "partial/_footer.html" %} 38 |
39 | 40 | 41 | 42 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /users/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /users/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from .views import * 3 | 4 | app_name = "users" 5 | urlpatterns = [ 6 | path("login/", login, name="login"), 7 | path("register/", register, name="register"), 8 | path("connect/", connect, name="connect"), 9 | path("reconnect/", reconnect, name="reconnect"), 10 | path("data/", data, name="data"), 11 | path("data/sync_mastodon", sync_mastodon, name="sync_mastodon"), 12 | path("data/clear_data", clear_data, name="clear_data"), 13 | path("logout/", logout, name="logout"), 14 | path("layout/", set_layout, name="set_layout"), 15 | path("OAuth2_login/", OAuth2_login, name="OAuth2_login"), 16 | path("/followers/", followers, name="followers"), 17 | path("/following/", following, name="following"), 18 | path("report/", report, name="report"), 19 | path("manage_report/", manage_report, name="manage_report"), 20 | ] 21 | --------------------------------------------------------------------------------