├── .dockerignore
├── .editorconfig
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── .tool-versions
├── .vscode
├── settings.json
└── vetur
│ └── snippets
│ └── vue-class-component.vue
├── Dockerfile
├── LICENSE
├── README.md
├── assets
├── README.md
├── buefy.png
└── scss
│ ├── _variables.scss
│ └── app.scss
├── components
├── Comment.vue
├── CommentParameter.vue
├── DeleteStamp.vue
├── README.md
├── StampList.vue
└── UploadStamp.vue
├── docker-compose.yml
├── docs
└── niconico.gif
├── extension
├── icons
│ └── convert.sh
├── images
│ ├── 404.png
│ └── logo.png
├── manifest.json
├── scripts
│ ├── background.ts
│ └── content_script.ts
└── styles
│ └── content_style.css
├── jsconfig.json
├── layouts
├── README.md
└── default.vue
├── messages
├── index.js
└── index.ts
├── middleware
└── README.md
├── nuxt.config.ts
├── package-lock.json
├── package.json
├── pages
├── README.md
├── admin.vue
└── index.vue
├── plugins
├── README.md
└── axios-accessor.ts
├── server
├── api.ts
├── auth.ts
├── config.ts
├── data.ts
├── extension.ts
├── index.ts
├── io.ts
├── nuxt.d.ts
├── setting.ts
└── storage.ts
├── static
├── README.md
└── favicon.ico
├── store
├── README.md
├── index.ts
└── stamps.ts
├── tsconfig-webpack.json
├── tsconfig.json
├── utils
├── api.ts
└── store-accessor.ts
└── webpack.config.ts
/.dockerignore:
--------------------------------------------------------------------------------
1 | data/
2 | dist/
3 | docs/
4 | node_modules/
5 | LICENSE
6 | README.md
7 |
8 | # Ignore version control files:
9 | .git/
10 | .gitignore
11 |
12 | # Ignore docker and environment files:
13 | **/*Dockerfile
14 | docker-compose.yml
15 | **/*.env
16 | .dockerignore
17 |
18 | # Ignore log files:
19 | log/*.log
20 |
21 | # Ignore OS artifacts:
22 | **/.DS_Store
23 |
24 | # Ignore nuxt.js files:
25 | .nuxt
26 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | browser: true,
5 | node: true,
6 | },
7 | extends: ['@nuxtjs/eslint-config-typescript', 'plugin:prettier/recommended', 'plugin:nuxt/recommended'],
8 | plugins: [],
9 | // add your custom rules here
10 | rules: {},
11 | };
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /data
2 |
3 | # Created by .ignore support plugin (hsz.mobi)
4 | ### Node template
5 | # Logs
6 | /logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 |
24 | # nyc test coverage
25 | .nyc_output
26 |
27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
28 | .grunt
29 |
30 | # Bower dependency directory (https://bower.io/)
31 | bower_components
32 |
33 | # node-waf configuration
34 | .lock-wscript
35 |
36 | # Compiled binary addons (https://nodejs.org/api/addons.html)
37 | build/Release
38 |
39 | # Dependency directories
40 | node_modules/
41 | jspm_packages/
42 |
43 | # TypeScript v1 declaration files
44 | typings/
45 |
46 | # Optional npm cache directory
47 | .npm
48 |
49 | # Optional eslint cache
50 | .eslintcache
51 |
52 | # Optional REPL history
53 | .node_repl_history
54 |
55 | # Output of 'npm pack'
56 | *.tgz
57 |
58 | # Yarn Integrity file
59 | .yarn-integrity
60 |
61 | # dotenv environment variables file
62 | .env
63 |
64 | # parcel-bundler cache (https://parceljs.org/)
65 | .cache
66 |
67 | # next.js build output
68 | .next
69 |
70 | # nuxt.js build output
71 | .nuxt
72 |
73 | # Nuxt generate
74 | dist
75 |
76 | # vuepress build output
77 | .vuepress/dist
78 |
79 | # Serverless directories
80 | .serverless
81 |
82 | # IDE / Editor
83 | .idea
84 |
85 | # Service worker
86 | sw.*
87 |
88 | # macOS
89 | .DS_Store
90 |
91 | # Vim swap files
92 | *.swp
93 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "arrowParens": "always",
4 | "printWidth": 140,
5 | "singleQuote": true,
6 | "trailingComma": "es5",
7 | "endOfLine": "lf"
8 | }
9 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | nodejs 16.20.2
2 | python 3.11.11
3 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.tabSize": 2,
3 | "eslint.alwaysShowStatus": true,
4 | "eslint.lintTask.enable": true,
5 | "eslint.validate": [ "javascript", "typescript", "vue" ],
6 | "files.trimFinalNewlines": true,
7 | "typescript.updateImportsOnFileMove.enabled": "always",
8 | "vetur.format.defaultFormatter.js": "none",
9 | "typescript.tsdk": "node_modules/typescript/lib",
10 | "editor.codeActionsOnSave": {
11 | "source.fixAll.eslint": "explicit"
12 | },
13 | }
14 |
--------------------------------------------------------------------------------
/.vscode/vetur/snippets/vue-class-component.vue:
--------------------------------------------------------------------------------
1 |
2 | ${0}
3 |
4 |
5 |
12 |
13 |
15 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-slim
2 |
3 | RUN apt-get update && apt-get install -y \
4 | tini \
5 | bash \
6 | imagemagick \
7 | build-essential \
8 | python3 \
9 | && rm -rf /var/lib/apt/lists/*
10 |
11 | WORKDIR /app
12 |
13 | COPY package.json package-lock.json ./
14 | RUN npm ci
15 |
16 | ENV SERVER_URL SERVER_URL_SHOLD_BE_REPLACED
17 |
18 | COPY . .
19 | RUN npm run build
20 |
21 | ENV HOST 0.0.0.0
22 | ENV PORT 8080
23 | ENV NODE_ENV production
24 | ENV TS_NODE_PROJECT server/tsconfig.json
25 | ENV SERVER_URL http://localhost:8080
26 |
27 | EXPOSE 8080
28 |
29 | ENTRYPOINT ["tini", "--"]
30 | CMD ["npx", "nuxt", "start"]
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2015-2020 Hideyuki TAKEUCHI
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | NicoNico SPEENYA
2 | ====
3 |
4 | [](LICENSE)
5 |
6 | ブラウザに、ニコニコ動画のように右から左に流れるコメントを表示する、Chrome機能拡張とそのサーバプログラムです。
7 |
8 | スタンプボタンを押すと画像が浮き上がったりします。
9 |
10 | 主にブラウザを使ったプレゼンテーションで使用すると(いわゆるニコニコメソッド)、視聴者とコミュニケーションがとれて良い感じです。
11 |
12 | Google Slidesやネット上のPDFをChromeで開いた上にも表示出来ます(ただし、HTTPSで公開されたサーバが必要)。
13 |
14 | 注意: 細かいこと考えてないので、このソースコードのまま一般公開サーバとか作ると大変なことになる気がします。
15 |
16 |
17 |
18 | ## 開発動機
19 |
20 | 社内の会議の所帯が大きくなり一人一人が発言しづらくなってきたため、コミュニケーションを促進するために作りました。
21 |
22 | ## 参考
23 |
24 | [ニコニコメソッドプレゼンを全社会議に取り入れてみたら会議が面白くなった](http://tech.uzabase.com/entry/2015/06/01/143202)
25 |
26 | ## [WIP]動かし方
27 |
28 | ### Docker
29 |
30 | ```bash
31 | export DOMAIN_NAME=[domain-name] # 適宜指定
32 |
33 | docker run --rm \
34 | -v $(pwd)/data/:/app/data/ \
35 | -p 8080:8080 -e "SERVER_URL=https://${DOMAIN_NAME}" \
36 | chimerast/niconico-speenya:develop
37 | ```
38 |
39 | ### Cloud Run
40 |
41 | 参考資料: https://cloud.google.com/run/docs/mapping-custom-domains
42 |
43 | ```bash
44 | export PROJECT_ID=[project-id] # 適宜指定
45 | export DOMAIN_NAME=[domain-name] # 適宜指定
46 |
47 | docker pull chimerast/niconico-speenya:develop
48 | docker tag chimerast/niconico-speenya:develop gcr.io/${PROJECT_ID}/niconico-speenya
49 | docker push gcr.io/${PROJECT_ID}/niconico-speenya
50 | gcloud run deploy niconico-speenya \
51 | --project=${PROJECT_ID} \
52 | --platform=managed \
53 | --region=asia-northeast1 \
54 | --allow-unauthenticated \
55 | --image=gcr.io/${PROJECT_ID}/niconico-speenya \
56 | --cpu=1 \
57 | --memory=256Mi \
58 | --max-instances=1 \
59 | --set-env-vars="SERVER_URL=https://${DOMAIN_NAME}"
60 | gcloud beta run domain-mappings create \
61 | --project=${PROJECT_ID} \
62 | --platform=managed \
63 | --region=asia-northeast1 \
64 | --service=niconico-speenya \
65 | --domain=${DOMAIN_NAME}
66 | ```
67 |
68 | ### 機能拡張
69 |
70 | `https://[DOMAIN_NAME]/extensions.zip` にアクセスすると、zipファイルがダウンロードできるので、それを `chrome://extensions` で表示される、機能拡張一覧ページにドラッグ&ドロップしてください。
71 |
72 | ## ライセンス
73 |
74 | [Apache License 2.0](LICENSE)
75 |
--------------------------------------------------------------------------------
/assets/README.md:
--------------------------------------------------------------------------------
1 | # ASSETS
2 |
3 | **This directory is not required, you can delete it if you don't want to use it.**
4 |
5 | This directory contains your un-compiled assets such as LESS, SASS, or JavaScript.
6 |
7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked).
8 |
--------------------------------------------------------------------------------
/assets/buefy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chimerast/niconico-speenya/4ac7f9a8aad93eb335d71018e3ab45e8c7d828ab/assets/buefy.png
--------------------------------------------------------------------------------
/assets/scss/_variables.scss:
--------------------------------------------------------------------------------
1 | @import "~bulma/sass/utilities/initial-variables";
2 | @import "~bulma/sass/utilities/functions";
3 |
4 | // 1. Set your own initial variables and derived
5 | // variables in _variables.scss
6 |
7 | $family-sans-serif: "Gill Sans", sans-serif;
8 | $primary: $grey-dark;
9 |
10 | // 2. Setup your Custom Colors
11 | @import "~bulma/sass/utilities/derived-variables";
12 |
13 | $section-padding: 1.5rem 1.5rem;
14 | $footer-padding: 0.5rem 1.5rem;
15 |
16 | // 3. Add new color variables to the color map.
17 | @import "~bulma/sass/base/animations";
18 | @import "~bulma/sass/utilities/mixins";
19 | @import "~bulma/sass/utilities/controls";
20 |
--------------------------------------------------------------------------------
/assets/scss/app.scss:
--------------------------------------------------------------------------------
1 | @import "variables";
2 |
3 | @import "~bulma";
4 | @import "~buefy/src/scss/buefy";
5 |
6 | // 4. Provide custom buefy overrides and site styles here
7 |
8 | html {
9 | touch-action: manipulation;
10 | }
11 |
--------------------------------------------------------------------------------
/components/Comment.vue:
--------------------------------------------------------------------------------
1 |
2 | .comment
3 | form(@submit.prevent="postComment")
4 | b-field(label="Comment" horizontal)
5 | b-field
6 | b-input(v-model="comment" placeholder="Please input your comment" expanded)
7 | .control
8 | b-button(type="is-primary" icon-left="comment" native-type="submit")
9 | comment-parameter(v-model="size" label="Size" :values="{ big: 15, medium: 10, small: 8 }")
10 | comment-parameter(v-model="speed" label="Speed" :values="{ fast: 1500, medium: 2000, slow: 3000 }")
11 | comment-parameter(v-model="color" label="Color" :values="{ black: 'black', red: 'red', blue: 'blue', green: 'green' }")
12 |
13 |
14 |
43 |
--------------------------------------------------------------------------------
/components/CommentParameter.vue:
--------------------------------------------------------------------------------
1 |
2 | .parameter.field.is-horizontal
3 | .field-label.is-normal
4 | label.label {{ label }}
5 | .field-body
6 | b-field
7 | b-radio-button(v-for="[l, v] in Object.entries(values)" v-model="nativeValue" :native-value="v" :key="l") {{ l }}
8 |
9 |
10 |
29 |
30 |
44 |
--------------------------------------------------------------------------------
/components/DeleteStamp.vue:
--------------------------------------------------------------------------------
1 |
2 | .stamp.field.is-horizontal
3 | .field-label.is-normal
4 | label.label Stamp
5 | .field-body
6 | .stamp-container
7 | draggable(v-model="stamps" ghost-class="ghost")
8 | .button.is-static(v-for="stamp in stamps" :key="stamp.id" :class="{ stamp: stamp.label === '' }")
9 | img.image(:src="`/storage/stamps/${stamp.path}`")
10 | span(v-if="stamp.label !== ''") {{ stamp.label }}
11 | button.delete(@click="deleteStamp(stamp.id)")
12 |
13 |
14 |
37 |
38 |
72 |
--------------------------------------------------------------------------------
/components/README.md:
--------------------------------------------------------------------------------
1 | # COMPONENTS
2 |
3 | **This directory is not required, you can delete it if you don't want to use it.**
4 |
5 | The components directory contains your Vue.js Components.
6 |
7 | _Nuxt.js doesn't supercharge these components._
8 |
--------------------------------------------------------------------------------
/components/StampList.vue:
--------------------------------------------------------------------------------
1 |
2 | .stamp.field.is-horizontal
3 | .field-label.is-normal
4 | label.label Stamp
5 | .field-body
6 | .stamp-container
7 | button.button(v-for="stamp in stamps" @click="postStamp(stamp.path)" :class="{ stamp: stamp.label === '' }")
8 | img.image(:src="`/storage/stamps/${stamp.path}`")
9 | span(v-if="stamp.label !== ''") {{ stamp.label }}
10 |
11 |
12 |
29 |
30 |
56 |
--------------------------------------------------------------------------------
/components/UploadStamp.vue:
--------------------------------------------------------------------------------
1 |
2 | .upload-stamp
3 | form(@submit.prevent="upload")
4 | b-field(label="Stamp" horizontal)
5 | b-upload.is-clearfix(v-model="file" :disabled="uploading" drag-drop)
6 | section.section
7 | .content.has-text-centered
8 | template(v-if="image === undefined")
9 | p: b-icon(icon="upload" size="is-large")
10 | p Drop your files here or click to upload
11 | img(v-else :src="image" style="max-width: 128px; max-height: 128px;")
12 | b-field(label="Label" horizontal)
13 | b-input(v-model="label" expanded)
14 | b-field(horizontal)
15 | .control
16 | b-button(type="is-primary" icon-left="upload" native-type="submit" :disabled="!uploadable" :class="{ 'is-loading': uploading }") Upload
17 |
18 |
19 |
63 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | web:
4 | image: chimerast/niconico-speenya:develop
5 | environment:
6 | SERVER_URL: http://localhost:8080
7 | ports:
8 | - 8080:8080
9 | volumes:
10 | - ./data/:/app/data/
11 |
--------------------------------------------------------------------------------
/docs/niconico.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chimerast/niconico-speenya/4ac7f9a8aad93eb335d71018e3ab45e8c7d828ab/docs/niconico.gif
--------------------------------------------------------------------------------
/extension/icons/convert.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | IMAGE_FILE=$(dirname $0)/../images/logo.png
4 | TARGET_DIR=$1
5 |
6 | if [ -f /app/data/logo.png ]; then
7 | IMAGE_FILE=/app/data/logo.png
8 | fi
9 |
10 | function convert_icon {
11 | convert $IMAGE_FILE -resize $1x$1 $TARGET_DIR/icon$1.png
12 | }
13 |
14 | function convert_icon_disabled {
15 | convert $IMAGE_FILE -resize $1x$1 -channel a -evaluate subtract 50% $TARGET_DIR/icon$1_disabled.png
16 | }
17 |
18 | function convert_icon_chromestore {
19 | convert $IMAGE_FILE -resize 96x96 -bordercolor none -border 16 $TARGET_DIR/icon128_chromestore.png
20 | }
21 |
22 | mkdir -p $TARGET_DIR
23 |
24 | convert_icon 16
25 | convert_icon 48
26 | convert_icon 128
27 |
28 | convert_icon 16
29 | convert_icon 24
30 | convert_icon 32
31 | convert_icon_disabled 16
32 | convert_icon_disabled 24
33 | convert_icon_disabled 32
34 |
35 | convert_icon_chromestore
36 |
--------------------------------------------------------------------------------
/extension/images/404.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chimerast/niconico-speenya/4ac7f9a8aad93eb335d71018e3ab45e8c7d828ab/extension/images/404.png
--------------------------------------------------------------------------------
/extension/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chimerast/niconico-speenya/4ac7f9a8aad93eb335d71018e3ab45e8c7d828ab/extension/images/logo.png
--------------------------------------------------------------------------------
/extension/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "niconico-speenya",
4 | "version": "PACKAGE_VERSION_SHOLD_BE_REPLACED",
5 | "description": "The niconico method presentation for Google Chrome",
6 |
7 | "icons": {
8 | "16": "icons/icon16.png",
9 | "48": "icons/icon48.png",
10 | "128": "icons/icon128.png"
11 | },
12 |
13 | "action": {
14 | "default_icon": {
15 | "16": "icons/icon16.png",
16 | "24": "icons/icon24.png",
17 | "32": "icons/icon32.png"
18 | },
19 | "default_title": "NicoNico SPEENYA"
20 | },
21 | "background": {
22 | "service_worker": "scripts/background.js"
23 | },
24 |
25 | "content_scripts": [
26 | {
27 | "matches": ["*://*/*"],
28 | "css": ["styles/content_style.css"],
29 | "js": ["scripts/content_script.js", "scripts/vendor.js"]
30 | }
31 | ],
32 | "web_accessible_resources": [
33 | {
34 | "resources": ["images/*"],
35 | "matches": ["*://*/*"]
36 | }
37 | ],
38 | "permissions": [
39 | "contextMenus",
40 | "tabs",
41 | "storage"
42 | ],
43 | "host_permissions": [
44 | "*://*/*"
45 | ]
46 | }
47 |
--------------------------------------------------------------------------------
/extension/scripts/background.ts:
--------------------------------------------------------------------------------
1 | /* global chrome */
2 |
3 | function extensionIcon(enabled: boolean): chrome.browserAction.TabIconDetails {
4 | const postfix = enabled ? '' : '_disabled';
5 | return {
6 | path: {
7 | '16': `icons/icon16${postfix}.png`,
8 | '24': `icons/icon24${postfix}.png`,
9 | '32': `icons/icon32${postfix}.png`,
10 | },
11 | };
12 | }
13 |
14 | chrome.storage.sync.get({ enabled: true }, (items) => {
15 | const enabled = items.enabled as boolean;
16 | chrome.browserAction.setIcon(extensionIcon(enabled));
17 | });
18 |
19 | chrome.browserAction.onClicked.addListener((_tab) => {
20 | chrome.storage.sync.get({ enabled: true }, (items) => {
21 | const toggled = !(items.enabled as boolean);
22 | chrome.storage.sync.set({ enabled: toggled });
23 | chrome.browserAction.setIcon(extensionIcon(toggled));
24 | });
25 | });
26 |
27 | chrome.contextMenus.create({
28 | title: 'Show webcam',
29 | contexts: ['browser_action'],
30 | onclick: () => {
31 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
32 | tabs.forEach((tab) => chrome.tabs.sendMessage(tab.id ?? 0, 'show_webcam'));
33 | });
34 | },
35 | });
36 |
--------------------------------------------------------------------------------
/extension/scripts/content_script.ts:
--------------------------------------------------------------------------------
1 | /* global chrome */
2 | import io, { Socket } from 'socket.io-client';
3 |
4 | import { CommentJson, StampJson } from '@/messages';
5 |
6 | const APP_ID = chrome.runtime.id;
7 | const APP_VERSION = chrome.runtime.getManifest().version;
8 |
9 | const NOT_FOUND_IMAGE_URL = `chrome-extension://${APP_ID}/images/404.png`;
10 |
11 | class SpeenyaClient {
12 | private socket: Socket;
13 | private video: HTMLVideoElement | undefined;
14 |
15 | constructor(private readonly host: string) {
16 | this.socket = io(host, { autoConnect: false });
17 |
18 | this.socket.on('comment', (comment: CommentJson) => this.handleComment(comment));
19 | this.socket.on('stamp', (stamp: StampJson) => this.handleStamp(stamp));
20 | }
21 |
22 | public connect(): void {
23 | this.socket.connect();
24 | this.prefetchStamps();
25 | console.log(`niconico speenya v${APP_VERSION}: connect to ${this.host}`);
26 | }
27 |
28 | public disconnect(): void {
29 | this.socket.disconnect();
30 | console.log(`niconico speenya v${APP_VERSION}: disconnect from ${this.host}`);
31 | }
32 |
33 | private prefetchStamps(): void {
34 | fetch(`${this.host}/api/stamps/urls`)
35 | .then((res) => res.json())
36 | .then((urls) => {
37 | (urls as string[]).forEach((url) => {
38 | new Image().src = url;
39 | });
40 | });
41 | }
42 |
43 | private rootElement(): Element {
44 | return document.fullscreenElement !== null ? document.fullscreenElement : document.body;
45 | }
46 |
47 | private handleComment(comment: CommentJson): void {
48 | const body = comment.body;
49 | const color = comment.color ?? '#000000';
50 | const size = comment.size ?? 10;
51 | const duration = comment.duration ?? 2000;
52 | const easing = comment.easing ?? 'linear';
53 |
54 | const node = document.createElement('div');
55 |
56 | node.classList.add('niconico_speenya__comment');
57 |
58 | node.style.fontSize = size + 'vh';
59 | node.style.color = color;
60 |
61 | node.textContent = body;
62 |
63 | const root = this.rootElement();
64 | root.appendChild(node);
65 |
66 | const displaying = Array.from(document.querySelectorAll('.niconico_speenya__comment')).flatMap((element) => {
67 | const node = element as HTMLElement;
68 | if (node.offsetLeft + node.offsetWidth <= window.innerWidth) {
69 | return [];
70 | } else {
71 | return [
72 | {
73 | top: node.offsetTop,
74 | bottom: node.offsetTop + node.offsetHeight,
75 | },
76 | ];
77 | }
78 | });
79 |
80 | let top = 0;
81 | for (let i = 0; i < 10; ++i) {
82 | top = this.rand(window.innerHeight - node.offsetHeight);
83 | if (displaying.every((d) => top >= d.bottom || top + node.offsetHeight <= d.top)) break;
84 | }
85 |
86 | node.style.top = top + 'px';
87 | node.style.left = window.innerWidth + 'px';
88 |
89 | const keyframes: Keyframe[] = [
90 | // keyframes
91 | { left: window.innerWidth + 'px' },
92 | { left: -node.offsetWidth + 'px' },
93 | ];
94 |
95 | const options: KeyframeAnimationOptions = {
96 | duration: (duration * (window.innerWidth + node.offsetWidth)) / window.innerWidth,
97 | iterations: 1,
98 | easing,
99 | };
100 |
101 | const animation = node.animate(keyframes, options);
102 | animation.onfinish = () => node.remove();
103 | }
104 |
105 | private handleStamp(stamp: StampJson): void {
106 | const url = stamp.url ?? NOT_FOUND_IMAGE_URL;
107 | const duration = stamp.duration ?? 1000;
108 | const easing = 'ease';
109 |
110 | const node = document.createElement('img');
111 |
112 | node.classList.add('niconico_speenya__stamp');
113 |
114 | const animation = () => {
115 | const root = this.rootElement();
116 | root.appendChild(node);
117 |
118 | node.style.left = this.rand(window.innerWidth) - node.width / 2 + 'px';
119 | node.style.top = this.rand(window.innerHeight) - node.height / 2 + 'px';
120 |
121 | const keyframes: Keyframe[] = [
122 | // keyframes
123 | { opacity: 0.0, transform: 'scale(0.2, 0.2) translate(0, 20px)' },
124 | { opacity: 1.0, transform: 'scale(0.5, 0.5) translate(0, 0px)' },
125 | { opacity: 0.0, transform: 'scale(1.0, 1.0) translate(0, -50px)' },
126 | ];
127 |
128 | const options: KeyframeAnimationOptions = {
129 | duration,
130 | iterations: 1,
131 | easing,
132 | };
133 |
134 | const animation = node.animate(keyframes, options);
135 | animation.onfinish = () => node.remove();
136 | };
137 |
138 | node.addEventListener('load', () => animation());
139 | node.addEventListener('error', () => {
140 | node.src = NOT_FOUND_IMAGE_URL;
141 | animation();
142 | });
143 |
144 | node.src = url.startsWith('/') ? `${this.host}${url}` : url;
145 | }
146 |
147 | public showWebcam(): void {
148 | if (this.video !== undefined) return;
149 |
150 | const node = document.createElement('video');
151 |
152 | node.classList.add('niconico_speenya__webcam');
153 | node.autoplay = true;
154 |
155 | this.video = node;
156 |
157 | this.rootElement().appendChild(node);
158 |
159 | document.addEventListener('fullscreenchange', () => {
160 | node.remove();
161 | this.rootElement().appendChild(node);
162 | });
163 |
164 | navigator.mediaDevices
165 | .getUserMedia({
166 | audio: false,
167 | video: {
168 | width: 1280,
169 | height: 720,
170 | },
171 | })
172 | .then((stream) => {
173 | node.srcObject = stream;
174 | });
175 | }
176 |
177 | public hideWebcam(): void {
178 | if (this.video === undefined) return;
179 |
180 | this.video.remove();
181 | this.video = undefined;
182 | }
183 |
184 | private rand(max: number) {
185 | return Math.floor(max * Math.random());
186 | }
187 | }
188 |
189 | const speenya = new SpeenyaClient(process.env.SERVER_URL!);
190 |
191 | chrome.storage.sync.get({ enabled: true }, (items) => {
192 | if (items.enabled) {
193 | speenya.connect();
194 | } else {
195 | speenya.disconnect();
196 | }
197 | });
198 |
199 | chrome.storage.onChanged.addListener((changes, namespace) => {
200 | if (namespace !== 'sync') return;
201 |
202 | if (changes.enabled) {
203 | if (changes.enabled.newValue) {
204 | speenya.connect();
205 | } else {
206 | speenya.disconnect();
207 | }
208 | }
209 | });
210 |
211 | chrome.runtime.onMessage.addListener((message) => {
212 | if (message === 'show_webcam') {
213 | speenya.showWebcam();
214 | }
215 | });
216 |
--------------------------------------------------------------------------------
/extension/styles/content_style.css:
--------------------------------------------------------------------------------
1 | .niconico_speenya__comment {
2 | position: fixed;
3 | top: -9999px;
4 | left: -9999px;
5 | font-family: sans-serif;
6 | font-weight: bold;
7 | text-shadow: -2px -2px 0px #fff, -2px 2px 0px #fff, 2px -2px 0px #fff, 2px 2px 0px #fff;
8 | white-space: pre;
9 | z-index: 2147483647;
10 | pointer-events: none;
11 | }
12 |
13 | .niconico_speenya__stamp {
14 | position: fixed;
15 | top: -9999px;
16 | left: -9999px;
17 | opacity: 0.0;
18 | z-index: 2147483647;
19 | pointer-events: none;
20 | }
21 |
22 | .niconico_speenya__webcam {
23 | position: fixed;
24 | bottom: 2vh;
25 | right: 2vh;
26 | width: calc(24vh * 16 / 9);
27 | height: 24vh;
28 | z-index: 2147483647;
29 | pointer-events: none;
30 | }
31 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "~/*": ["./*"],
6 | "@/*": ["./*"],
7 | "~~/*": ["./*"],
8 | "@@/*": ["./*"]
9 | }
10 | },
11 | "exclude": ["node_modules", ".nuxt", "dist"]
12 | }
13 |
--------------------------------------------------------------------------------
/layouts/README.md:
--------------------------------------------------------------------------------
1 | # LAYOUTS
2 |
3 | **This directory is not required, you can delete it if you don't want to use it.**
4 |
5 | This directory contains your Application Layouts.
6 |
7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/views#layouts).
8 |
--------------------------------------------------------------------------------
/layouts/default.vue:
--------------------------------------------------------------------------------
1 |
2 | .main
3 | .content
4 | nuxt
5 | footer.footer
6 | .has-text-right niconico-speenya © 2015-2020 @chimerast
7 |
8 |
9 |
20 |
21 |
32 |
--------------------------------------------------------------------------------
/messages/index.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | exports.__esModule = true;
3 |
--------------------------------------------------------------------------------
/messages/index.ts:
--------------------------------------------------------------------------------
1 | export interface CommentJson {
2 | body: string;
3 | color?: string;
4 | size?: number;
5 | duration?: number;
6 | easing?: string;
7 | }
8 |
9 | export interface StampJson {
10 | path?: string;
11 | url?: string;
12 | duration?: number;
13 | easing?: string;
14 | }
15 |
16 | export interface Setting {
17 | key: string;
18 | value: string;
19 | }
20 |
21 | export interface Stamp {
22 | id: number;
23 | label: string;
24 | path: string;
25 | contentType: string;
26 | }
27 |
--------------------------------------------------------------------------------
/middleware/README.md:
--------------------------------------------------------------------------------
1 | # MIDDLEWARE
2 |
3 | **This directory is not required, you can delete it if you don't want to use it.**
4 |
5 | This directory contains your application middleware.
6 | Middleware let you define custom functions that can be run before rendering either a page or a group of pages.
7 |
8 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware).
9 |
--------------------------------------------------------------------------------
/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import { NuxtConfig } from '@nuxt/types';
2 | import { io } from './server/io';
3 |
4 | const config: NuxtConfig = {
5 | // Disable server-side rendering: https://go.nuxtjs.dev/ssr-mode
6 | ssr: false,
7 |
8 | // Global page headers: https://go.nuxtjs.dev/config-head
9 | head: {
10 | title: process.env.npm_package_name || '',
11 | meta: [
12 | { charset: 'utf-8' },
13 | { name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' },
14 | {
15 | hid: 'description',
16 | name: 'description',
17 | content: process.env.npm_package_description || '',
18 | },
19 | ],
20 | link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
21 | },
22 |
23 | // Global CSS: https://go.nuxtjs.dev/config-css
24 | css: ['~/assets/scss/app.scss'],
25 |
26 | // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
27 | plugins: ['~/plugins/axios-accessor.ts'],
28 |
29 | // Auto import components: https://go.nuxtjs.dev/config-components
30 | components: true,
31 |
32 | // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
33 | buildModules: [
34 | // Doc: https://typescript.nuxtjs.org/
35 | '@nuxt/typescript-build',
36 | // Doc: https://github.com/nuxt-community/eslint-module
37 | '@nuxtjs/eslint-module',
38 | ],
39 |
40 | // Modules: https://go.nuxtjs.dev/config-modules
41 | modules: [
42 | // https://go.nuxtjs.dev/buefy
43 | 'nuxt-buefy',
44 | // https://go.nuxtjs.dev/axios
45 | '@nuxtjs/axios',
46 | ],
47 |
48 | // Axios module configuration: https://go.nuxtjs.dev/config-axios
49 | axios: {
50 | baseURL: '/api',
51 | },
52 |
53 | // Build Configuration: https://go.nuxtjs.dev/config-build
54 | build: {},
55 |
56 | generate: {
57 | dir: 'dist/public',
58 | },
59 |
60 | hooks: {
61 | listen: (server) => {
62 | io.attach(server);
63 | },
64 | },
65 |
66 | serverMiddleware: [{ path: '/', handler: '~/server/index.ts' }],
67 |
68 | styleResources: {
69 | scss: ['~/assets/scss/_variables.scss'],
70 | },
71 | };
72 |
73 | export default config;
74 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "niconico-speenya",
3 | "version": "0.2.1",
4 | "description": "The niconico method presentation for Google Chrome",
5 | "homepage": "https://github.com/chimerast/niconico-speenya#readme",
6 | "bugs": "https://github.com/chimerast/niconico-speenya/issues",
7 | "license": "Apache-2.0",
8 | "author": "Hideyuki TAKEUCHI ",
9 | "private": true,
10 | "scripts": {
11 | "dev": "npm-run-all --parallel dev_server dev_extension",
12 | "build": "npm-run-all clean build_server build_extension",
13 | "start": "nuxt start",
14 | "generate": "nuxt generate",
15 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
16 | "clean": "rm -rf dist",
17 | "build_server": "nuxt build",
18 | "build_extension": "TS_NODE_PROJECT=tsconfig-webpack.json webpack",
19 | "dev_server": "nuxt",
20 | "dev_extension": "TS_NODE_PROJECT=tsconfig-webpack.json webpack --watch"
21 | },
22 | "dependencies": {
23 | "@nuxt/typescript-runtime": "^2.1.0",
24 | "@nuxtjs/axios": "^5.13.6",
25 | "archiver": "^5.3.0",
26 | "better-sqlite3": "^7.4.1",
27 | "console-stamp": "^3.0.2",
28 | "cors": "^2.8.5",
29 | "dotenv": "^10.0.0",
30 | "express": "^4.17.1",
31 | "express-session": "^1.17.2",
32 | "multer": "^1.4.2",
33 | "nuxt": "^2.15.7",
34 | "nuxt-buefy": "^0.4.7",
35 | "passport": "^0.4.1",
36 | "replace-in-file": "^6.2.0",
37 | "socket.io": "^4.1.2",
38 | "socket.io-client": "^4.1.2",
39 | "vuedraggable": "^2.24.3",
40 | "vuex": "^3.6.2"
41 | },
42 | "devDependencies": {
43 | "@nuxt/types": "^2.15.7",
44 | "@nuxt/typescript-build": "^2.1.0",
45 | "@nuxtjs/eslint-config-typescript": "^6.0.1",
46 | "@nuxtjs/eslint-module": "^3.0.2",
47 | "@types/archiver": "^5.1.0",
48 | "@types/better-sqlite3": "^5.4.1",
49 | "@types/chrome": "0.0.145",
50 | "@types/copy-webpack-plugin": "^8.0.0",
51 | "@types/express-session": "^1.17.3",
52 | "@types/multer": "^1.4.6",
53 | "@types/passport-google-oauth20": "^2.0.8",
54 | "@types/webpack": "^5.28.0",
55 | "babel-eslint": "^10.1.0",
56 | "copy-webpack-plugin": "^6.4.1",
57 | "eslint": "^7.28.0",
58 | "eslint-config-prettier": "^8.3.0",
59 | "eslint-plugin-nuxt": "^2.0.0",
60 | "eslint-plugin-prettier": "^3.4.0",
61 | "eslint-plugin-vue": "^7.11.1",
62 | "npm-run-all": "^4.1.5",
63 | "nuxt-property-decorator": "^2.9.1",
64 | "passport-google-oauth20": "^2.0.0",
65 | "prettier": "^2.3.1",
66 | "pug": "^3.0.2",
67 | "pug-plain-loader": "^1.1.0",
68 | "sass": "^1.32.13",
69 | "sass-loader": "^10.2.0",
70 | "vuex-module-decorators": "^1.0.1",
71 | "webpack-cli": "^4.7.2"
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/pages/README.md:
--------------------------------------------------------------------------------
1 | # PAGES
2 |
3 | This directory contains your Application Views and Routes.
4 | The framework reads all the `*.vue` files inside this directory and creates the router of your application.
5 |
6 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing).
7 |
--------------------------------------------------------------------------------
/pages/admin.vue:
--------------------------------------------------------------------------------
1 |
2 | .container
3 | section.section
4 | .card
5 | .card-header
6 | .card-header-title Upload stamp
7 | .card-content
8 | upload-stamp
9 | section.section
10 | .card
11 | .card-header
12 | .card-header-title Delete stamp
13 | .card-content
14 | delete-stamp
15 |
16 |
17 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 | .container
3 | section.section
4 | stamp-list
5 | section.section
6 | comment
7 |
8 |
9 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/plugins/README.md:
--------------------------------------------------------------------------------
1 | # PLUGINS
2 |
3 | **This directory is not required, you can delete it if you don't want to use it.**
4 |
5 | This directory contains Javascript plugins that you want to run before mounting the root Vue.js application.
6 |
7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins).
8 |
--------------------------------------------------------------------------------
/plugins/axios-accessor.ts:
--------------------------------------------------------------------------------
1 | import { Plugin } from '@nuxt/types';
2 | import { initializeAxios } from '~/utils/api';
3 |
4 | const accessor: Plugin = ({ $axios }) => {
5 | initializeAxios($axios);
6 | };
7 |
8 | export default accessor;
9 |
--------------------------------------------------------------------------------
/server/api.ts:
--------------------------------------------------------------------------------
1 | import util from 'util';
2 | import express, { Router } from 'express';
3 | import multer from 'multer';
4 | import socketio from 'socket.io';
5 | import { StampJson, CommentJson } from '../messages';
6 | import { data } from './data';
7 | import { config } from './config';
8 | import { setting } from './setting';
9 |
10 | export function api(io: socketio.Server): Router {
11 | const storage = multer.diskStorage({
12 | destination: (_req, _file, cb) => cb(null, config.stamps),
13 | });
14 |
15 | const upload = util.promisify(multer({ storage }).single('file'));
16 |
17 | const api = express.Router();
18 |
19 | api.post('/messages/comment', (req, res) => {
20 | const msg = Object.assign({}, req.body) as CommentJson;
21 | io.emit('comment', msg);
22 | res.end();
23 | });
24 |
25 | api.post('/messages/stamp', (req, res) => {
26 | let stamp: StampJson;
27 | if (req.body?.path !== undefined) {
28 | const path = `/storage/stamps/${req.body.path}`;
29 | const sign = setting.signPath(path);
30 | stamp = Object.assign({ url: `${path}?s=${sign}` }, req.body) as StampJson;
31 | } else {
32 | stamp = Object.assign({}, req.body) as StampJson;
33 | }
34 | io.emit('stamp', stamp);
35 | res.end();
36 | });
37 |
38 | api.get('/stamps', (_req, res) => {
39 | res.json(data.getAllStamps());
40 | });
41 |
42 | api.get('/stamps/urls', (_req, res) => {
43 | res.json(
44 | data.getAllStamps().map((stamp) => {
45 | const path = `/storage/stamps/${stamp.path}`;
46 | const sign = setting.signPath(path);
47 | return `${config.serverUrl}${path}?s=${sign}`;
48 | })
49 | );
50 | });
51 |
52 | api.post('/stamps', async (req, res) => {
53 | await upload(req, res);
54 |
55 | const file = res.req?.file;
56 | if (file === undefined) return res.status(409).end();
57 |
58 | const label = req.body.label;
59 | const path = file.filename;
60 | const contentType = file.mimetype;
61 |
62 | data.addStamp(label, path, contentType);
63 | res.end();
64 | });
65 |
66 | api.delete('/stamps/:id', (req, res) => {
67 | const id = Number(req.params.id);
68 | data.deleteStamp(id);
69 | res.end();
70 | });
71 |
72 | api.post('/stamps/order', (req, res) => {
73 | data.updateStampOrder(req.body);
74 | res.end();
75 | });
76 |
77 | return api;
78 | }
79 |
--------------------------------------------------------------------------------
/server/auth.ts:
--------------------------------------------------------------------------------
1 | import { Express, NextFunction, Request, RequestHandler, Response } from 'express';
2 | import passport from 'passport';
3 | import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
4 | import { config } from './config';
5 | import { setting } from './setting';
6 |
7 | export function auth(app: Express): RequestHandler {
8 | const clientID = process.env.GOOGLE_CLIENT_ID;
9 | const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
10 | const hostDomain = process.env.GOOGLE_WORKSPACE_HOST_DOMAIN;
11 | const callbackURL = `${config.serverUrl}/auth/google/callback`;
12 |
13 | if (clientID == null || clientSecret == null) {
14 | return (_req: Request, _res: Response, next: NextFunction) => {
15 | return next();
16 | };
17 | }
18 |
19 | app.use(passport.initialize());
20 | app.use(passport.session());
21 |
22 | passport.serializeUser((user, done) => done(null, user));
23 | passport.deserializeUser((user, done) => done(null, user as Express.User));
24 |
25 | passport.use(
26 | new GoogleStrategy({ clientID, clientSecret, callbackURL }, (_accessToken, _refreshToken, profile, done) => {
27 | done(null, {
28 | id: profile.id,
29 | name: profile.displayName,
30 | email: profile.emails?.[0]?.value,
31 | });
32 | })
33 | );
34 |
35 | app.get('/auth/google', passport.authenticate('google', { scope: ['openid', 'email'], hd: hostDomain }));
36 |
37 | app.get('/auth/google/callback', passport.authenticate('google'), (_req, res) => {
38 | res.redirect('/');
39 | });
40 |
41 | function shouldBeAuthenticated(req: Request): boolean {
42 | const isAuthUrl = req.path.startsWith('/auth/');
43 | const isExtension = req.path.startsWith('/storage/') && req.query.s === setting.signPath(req.path);
44 | const isPrefetchUrl = req.path === '/api/stamps/urls';
45 |
46 | return !(isAuthUrl || isExtension || isPrefetchUrl);
47 | }
48 |
49 | return (req: Request, res: Response, next: NextFunction) => {
50 | if (shouldBeAuthenticated(req) && !req.isAuthenticated()) {
51 | return res.redirect('/auth/google');
52 | } else {
53 | return next();
54 | }
55 | };
56 | }
57 |
--------------------------------------------------------------------------------
/server/config.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import dotenv from 'dotenv';
3 |
4 | dotenv.config();
5 |
6 | const production = process.env.NODE_ENV === 'production';
7 | const serverUrl = process.env.SERVER_URL ?? 'http://localhost:3000';
8 | const dataDir = process.env.DATA_DIR ?? './data';
9 |
10 | fs.mkdirSync(`${dataDir}/stamps`, { recursive: true });
11 |
12 | class Config {
13 | public get production(): boolean {
14 | return production;
15 | }
16 |
17 | public get serverUrl(): string {
18 | return serverUrl;
19 | }
20 |
21 | public get database(): string {
22 | return `${dataDir}/database`;
23 | }
24 |
25 | public get stamps(): string {
26 | return `${dataDir}/stamps`;
27 | }
28 | }
29 |
30 | export const config = new Config();
31 |
--------------------------------------------------------------------------------
/server/data.ts:
--------------------------------------------------------------------------------
1 | import sqlite3, { Database } from 'better-sqlite3';
2 | import { Stamp } from '@/messages';
3 | import { config } from './config';
4 |
5 | class Data {
6 | private readonly db: Database;
7 |
8 | constructor(path: string = config.database) {
9 | this.db = sqlite3(path);
10 | this.db.exec(`
11 | CREATE TABLE IF NOT EXISTS settings (
12 | key TEXT PRIMARY KEY,
13 | value TEXT NOT NULL
14 | );
15 | CREATE TABLE IF NOT EXISTS tenants (
16 | id INTEGER PRIMARY KEY AUTOINCREMENT,
17 | label TEXT NOT NULL
18 | );
19 | CREATE TABLE IF NOT EXISTS stamps (
20 | id INTEGER PRIMARY KEY AUTOINCREMENT,
21 | createdAt DATETIME NOT NULL DEFAULT (datetime('now', 'utc')),
22 | label TEXT NOT NULL,
23 | path TEXT NOT NULL,
24 | contentType TEXT NOT NULL,
25 | "order" INTEGER NOT NULL DEFAULT 0
26 | );
27 | CREATE TABLE IF NOT EXISTS comments (
28 | id INTEGER PRIMARY KEY AUTOINCREMENT,
29 | createdAt DATETIME NOT NULL DEFAULT (datetime('now', 'utc')),
30 | tenant_id INTEGER NOT NULL,
31 | comment TEXT NOT NULL
32 | );
33 | `);
34 | }
35 |
36 | public getSetting(key: string, defaultValue: () => string): string {
37 | const value: string | undefined = this.db.prepare('SELECT value FROM settings WHERE key = ?').bind(key).pluck().get();
38 | if (value !== undefined) return value;
39 |
40 | const newValue = defaultValue();
41 | this.db.prepare('REPLACE INTO settings(key, value) VALUES(?, ?);').run(key, newValue);
42 | return newValue;
43 | }
44 |
45 | public getAllStamps(): Stamp[] {
46 | return this.db.prepare('SELECT * FROM stamps ORDER BY "order", id').all();
47 | }
48 |
49 | public getStamp(id: number): Stamp {
50 | return this.db.prepare('SELECT * FROM stamps WHERE id = ?').bind(id).get();
51 | }
52 |
53 | public getStampByPath(path: string): Stamp {
54 | return this.db.prepare('SELECT * FROM stamps WHERE path = ?').bind(path).get();
55 | }
56 |
57 | public addStamp(label: string, path: string, contentType: string): void {
58 | this.db.prepare('INSERT INTO stamps(label, path, contentType) VALUES(?, ?, ?)').run(label, path, contentType);
59 | }
60 |
61 | public deleteStamp(id: number): void {
62 | this.db.prepare('DELETE FROM stamps WHERE id = ?').run(id);
63 | }
64 |
65 | public updateStampOrder(orders: { id: number; order: number }[]) {
66 | const stmt = this.db.prepare('UPDATE stamps SET "order" = @order WHERE id = @id');
67 | for (const order of orders) {
68 | stmt.run(order);
69 | }
70 | }
71 | }
72 |
73 | export const data = new Data();
74 |
--------------------------------------------------------------------------------
/server/extension.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { execSync } from 'child_process';
3 | import express, { Router } from 'express';
4 | import archiver from 'archiver';
5 | import replace from 'replace-in-file';
6 | import { version } from '../package.json';
7 | import { config } from './config';
8 | import { setting } from './setting';
9 |
10 | replace.replaceInFile({
11 | files: './dist/extension/scripts/content_script.js',
12 | from: [/SERVER_URL_SHOLD_BE_REPLACED/g, /EXTENSION_SECRET_SHOLD_BE_REPLACED/g],
13 | to: [config.serverUrl, setting.extensionSecret],
14 | });
15 |
16 | replace.replaceInFile({
17 | files: './dist/extension/manifest.json',
18 | from: /PACKAGE_VERSION_SHOLD_BE_REPLACED/g,
19 | to: version ?? '0.0.0',
20 | });
21 |
22 | const script = path.resolve(__dirname, '../extension/icons/convert.sh');
23 | const target = path.resolve(__dirname, '../dist/extension/icons');
24 |
25 | execSync(`${script} ${target}`);
26 |
27 | export function extension(): Router {
28 | const extension = express.Router();
29 |
30 | extension.get('/', (_req, res) => {
31 | try {
32 | res.setHeader('Content-Type', 'application/zip');
33 | res.setHeader('Cache-Control', 'no-cache');
34 | res.setHeader('Content-Disposition', 'attachment; filename="extension.zip"');
35 |
36 | const archive = archiver('zip', {});
37 | archive.on('error', (_err) => res.status(404).end());
38 | archive.pipe(res);
39 |
40 | archive.directory('./dist/extension/', false);
41 | archive.finalize();
42 | } catch (err) {
43 | res.status(500).end();
44 | }
45 | });
46 |
47 | return extension;
48 | }
49 |
--------------------------------------------------------------------------------
/server/index.ts:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import cors from 'cors';
3 | import session from 'express-session';
4 | import { setting } from './setting';
5 | import { io } from './io';
6 | import { auth } from './auth';
7 | import { api } from './api';
8 | import { storage } from './storage';
9 | import { extension } from './extension';
10 |
11 | const app = express();
12 |
13 | app.use(cors({ origin: '*', methods: ['GET', 'POST'] }));
14 | app.use(session({ secret: setting.sessionSecret, resave: false, saveUninitialized: true, proxy: true, cookie: { secure: 'auto' } }));
15 | app.use(express.json());
16 | app.use(express.urlencoded({ extended: true }));
17 | app.use(auth(app));
18 |
19 | app.use('/api', api(io));
20 | app.use('/storage', storage());
21 | app.use('/extension.zip', extension());
22 |
23 | export default app;
24 |
--------------------------------------------------------------------------------
/server/io.ts:
--------------------------------------------------------------------------------
1 | import { Socket, Server } from 'socket.io';
2 | import consola from 'consola';
3 |
4 | export const io = new Server({ cors: { origin: '*', methods: ['GET', 'POST'] } });
5 |
6 | function logSocketEvent(socket: Socket, event: string) {
7 | consola.log(
8 | `[${new Date().toISOString()}] socket.io - ${event}: id=${socket.id}, remoteAddress=${socket.request.connection.remoteAddress}`
9 | );
10 | }
11 |
12 | io.on('connection', (socket) => {
13 | logSocketEvent(socket, 'connection');
14 |
15 | socket.on('disconnect', () => {
16 | logSocketEvent(socket, 'disconnect');
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/server/nuxt.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'nuxt' {
2 | const Nuxt: any;
3 | const Builder: any;
4 | export { Nuxt, Builder };
5 | }
6 |
--------------------------------------------------------------------------------
/server/setting.ts:
--------------------------------------------------------------------------------
1 | import crypto from 'crypto';
2 |
3 | import { data } from './data';
4 |
5 | const sessionSecret = data.getSetting('SESSION_SECRET', () => crypto.randomBytes(16).toString('base64'));
6 | const extensionSecret = data.getSetting('EXTENSION_SECRET', () => crypto.randomBytes(32).toString('base64'));
7 |
8 | class Setting {
9 | public get sessionSecret(): string {
10 | return sessionSecret;
11 | }
12 |
13 | public get extensionSecret(): string {
14 | return extensionSecret;
15 | }
16 |
17 | public signPath(path: string): string {
18 | const hmac = crypto.createHmac('sha256', extensionSecret);
19 | hmac.update(path);
20 | return hmac.digest('base64').replace(/[+/=]/g, (match) => ({ '+': '-', '/': '_', '=': '' }[match] ?? ''));
21 | }
22 | }
23 |
24 | export const setting = new Setting();
25 |
--------------------------------------------------------------------------------
/server/storage.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import express, { Router } from 'express';
3 | import { data } from './data';
4 | import { config } from './config';
5 |
6 | export function storage(): Router {
7 | const storage = express.Router();
8 |
9 | storage.get('/stamps/:path', (req, res) => {
10 | try {
11 | const stamp = data.getStampByPath(req.params.path);
12 |
13 | res.sendFile(path.resolve(__dirname, '..', `${config.stamps}/${stamp.path}`), {
14 | maxAge: '30d',
15 | immutable: true,
16 | headers: {
17 | 'content-type': stamp.contentType,
18 | },
19 | });
20 | } catch (err) {
21 | res.status(500).end();
22 | }
23 | });
24 |
25 | return storage;
26 | }
27 |
--------------------------------------------------------------------------------
/static/README.md:
--------------------------------------------------------------------------------
1 | # STATIC
2 |
3 | **This directory is not required, you can delete it if you don't want to use it.**
4 |
5 | This directory contains your static files.
6 | Each file inside this directory is mapped to `/`.
7 | Thus you'd want to delete this README.md before deploying to production.
8 |
9 | Example: `/static/robots.txt` is mapped as `/robots.txt`.
10 |
11 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static).
12 |
--------------------------------------------------------------------------------
/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chimerast/niconico-speenya/4ac7f9a8aad93eb335d71018e3ab45e8c7d828ab/static/favicon.ico
--------------------------------------------------------------------------------
/store/README.md:
--------------------------------------------------------------------------------
1 | # STORE
2 |
3 | **This directory is not required, you can delete it if you don't want to use it.**
4 |
5 | This directory contains your Vuex Store files.
6 | Vuex Store option is implemented in the Nuxt.js framework.
7 |
8 | Creating a file in this directory automatically activates the option in the framework.
9 |
10 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store).
11 |
--------------------------------------------------------------------------------
/store/index.ts:
--------------------------------------------------------------------------------
1 | import { Store } from 'vuex';
2 | import { initialiseStores } from '~/utils/store-accessor';
3 |
4 | const initializer = (store: Store) => initialiseStores(store);
5 |
6 | export const plugins = [initializer];
7 | export * from '~/utils/store-accessor';
8 |
--------------------------------------------------------------------------------
/store/stamps.ts:
--------------------------------------------------------------------------------
1 | import { Module, VuexModule, Action, Mutation } from 'vuex-module-decorators';
2 | import { Stamp } from '@/messages';
3 | import { $axios } from '~/utils/api';
4 |
5 | @Module({
6 | name: 'stamps',
7 | stateFactory: true,
8 | namespaced: true,
9 | })
10 | export default class Stamps extends VuexModule {
11 | stamps: Stamp[] = [];
12 |
13 | @Mutation
14 | setStamps(stamps: Stamp[]) {
15 | this.stamps = stamps;
16 | }
17 |
18 | @Action
19 | async fetchStamps() {
20 | const stamps: Stamp[] = await $axios.$get(`/stamps`);
21 | this.context.commit('setStamps', stamps);
22 | }
23 |
24 | @Action
25 | async addStamp(form: FormData) {
26 | await $axios.post('/stamps', form);
27 | await this.context.dispatch('fetchStamps');
28 | }
29 |
30 | @Action
31 | async deleteStamp(id: number) {
32 | await $axios.$delete(`/stamps/${id}`);
33 | await this.context.dispatch('fetchStamps');
34 | }
35 |
36 | @Action
37 | async updateOrder(stamps: Stamp[]) {
38 | await $axios.post(
39 | '/stamps/order',
40 | stamps.map((stamp, index) => {
41 | return {
42 | id: stamp.id,
43 | order: index,
44 | };
45 | })
46 | );
47 |
48 | this.context.commit('setStamps', stamps);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/tsconfig-webpack.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2018",
4 | "module": "commonjs",
5 | "moduleResolution": "node",
6 | "experimentalDecorators": true,
7 | "lib": [
8 | "esnext",
9 | "esnext.asynciterable",
10 | "dom"
11 | ],
12 | "resolveJsonModule": true,
13 | "esModuleInterop": true,
14 | "allowSyntheticDefaultImports": true,
15 | "allowJs": true,
16 | "sourceMap": true,
17 | "strict": true,
18 | "noEmit": false,
19 | "baseUrl": ".",
20 | "paths": {
21 | "~/*": [
22 | "./*"
23 | ],
24 | "@/*": [
25 | "./*"
26 | ]
27 | },
28 | "types": [
29 | "@types/node"
30 | ]
31 | },
32 | "exclude": [
33 | "node_modules",
34 | "dist"
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2018",
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "lib": [
7 | "ESNext",
8 | "ESNext.AsyncIterable",
9 | "DOM"
10 | ],
11 | "resolveJsonModule": true,
12 | "esModuleInterop": true,
13 | "allowJs": true,
14 | "sourceMap": true,
15 | "strict": true,
16 | "experimentalDecorators": true,
17 | "baseUrl": ".",
18 | "paths": {
19 | "~/*": [
20 | "./*"
21 | ],
22 | "@/*": [
23 | "./*"
24 | ]
25 | },
26 | "types": [
27 | "@nuxt/types",
28 | "@nuxtjs/axios",
29 | "@types/node",
30 | "chrome"
31 | ]
32 | },
33 | "exclude": [
34 | "node_modules",
35 | ".nuxt",
36 | "dist"
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/utils/api.ts:
--------------------------------------------------------------------------------
1 | import { NuxtAxiosInstance } from '@nuxtjs/axios';
2 |
3 | // eslint-disable-next-line import/no-mutable-exports
4 | let $axios: NuxtAxiosInstance;
5 |
6 | export function initializeAxios(axiosInstance: NuxtAxiosInstance) {
7 | $axios = axiosInstance;
8 | }
9 |
10 | export { $axios };
11 |
--------------------------------------------------------------------------------
/utils/store-accessor.ts:
--------------------------------------------------------------------------------
1 | import { Store } from 'vuex';
2 | import { getModule } from 'vuex-module-decorators';
3 | import Stamps from '~/store/stamps';
4 |
5 | // eslint-disable-next-line import/no-mutable-exports
6 | let stampStore: Stamps;
7 |
8 | function initialiseStores(store: Store): void {
9 | stampStore = getModule(Stamps, store);
10 | }
11 |
12 | export { initialiseStores, stampStore };
13 |
--------------------------------------------------------------------------------
/webpack.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { exec } from 'child_process';
3 | import { Configuration, DefinePlugin } from 'webpack';
4 | import CopyPlugin from 'copy-webpack-plugin';
5 |
6 | const config: Configuration = {
7 | mode: 'production',
8 | entry: {
9 | background: path.resolve(__dirname, './extension/scripts/background.ts'),
10 | content_script: path.resolve(__dirname, './extension/scripts/content_script.ts'),
11 | },
12 | output: {
13 | path: path.resolve(__dirname, './dist/extension/scripts'),
14 | filename: '[name].js',
15 | },
16 | module: {
17 | rules: [
18 | {
19 | test: /\.ts$/,
20 | use: 'ts-loader',
21 | exclude: /node_modules/,
22 | },
23 | ],
24 | },
25 | resolve: {
26 | extensions: ['.ts', '.tsx', '.js'],
27 | },
28 | plugins: [
29 | new DefinePlugin({
30 | 'process.env.SERVER_URL': JSON.stringify(process.env.SERVER_URL || 'http://localhost:3000'),
31 | }),
32 | new CopyPlugin({
33 | patterns: [
34 | {
35 | from: path.resolve(__dirname, './extension/manifest.json'),
36 | to: path.resolve(__dirname, './dist/extension'),
37 | },
38 | {
39 | from: path.resolve(__dirname, './extension/images'),
40 | to: path.resolve(__dirname, './dist/extension/images'),
41 | },
42 | {
43 | from: path.resolve(__dirname, './extension/styles'),
44 | to: path.resolve(__dirname, './dist/extension/styles'),
45 | },
46 | ],
47 | }),
48 | {
49 | apply: (compiler) => {
50 | compiler.hooks.afterEmit.tap('AfterEmitPlugin', (_compilation) => {
51 | const script = path.resolve(__dirname, './extension/icons/convert.sh');
52 | const target = path.resolve(__dirname, './dist/extension/icons');
53 | exec(`${script} ${target}`, (_error, stdout, stderr) => {
54 | if (stdout) process.stdout.write(stdout);
55 | if (stderr) process.stderr.write(stderr);
56 | });
57 | });
58 | },
59 | },
60 | ],
61 | optimization: {
62 | splitChunks: {
63 | name: 'vendor',
64 | chunks: 'initial',
65 | },
66 | },
67 | };
68 |
69 | export default config;
70 |
--------------------------------------------------------------------------------